如果你研究过 Linux 网络驱动大概率见过 snull 这个教学示例。它用软件模拟了一对虚拟网卡 snull0 和 snull1让你在没有物理设备的情况下也能完整体验网络设备驱动的注册、发送、接收和卸载流程。这套示例最值得学习的部分正是它的设备方法实现——也就是net_device_ops回调集合。本文就把这些方法逐个拆开讲清楚每个回调什么时候被调用、snull 是怎么实现它们的以及从中能总结出哪些可以迁移到真实驱动上的经验。1. 两个虚拟网卡背后snull 的设备方法全景1.1 snull 是什么为什么讲它snull 是 Linux 驱动开发教学里非常经典的一个假网卡示例。它不依赖 PCI、USB、平台设备这类真实总线模块加载的时候直接创建出两个网络接口通常是 snull0 和 snull1这两个接口之间可以互相收发数据。它把硬件如何产生中断、硬件如何发送数据、硬件如何更新状态这些依赖真实设备的部分全部改写成了软件模拟你不需要接触寄存器手册就能把网络驱动的主干逻辑看明白。对学习驱动的人来说snull 的价值在于它把网络设备驱动和内核网络栈之间的契约完整呈现了出来。你要学习的不是怎么操作某个厂商的寄存器而是struct net_device和协议栈之间的数据交接收规则。snull 用最精炼的代码覆盖了 open、stop、start_xmit、ioctl、get_stats 等主要设备方法这就是为什么我说它是设备方法入门最佳范本。真实网卡驱动的代码里可能有大量和硬件相关的寄存器操作但设备方法的主干结构和 snull 几乎是一一对应的。1.2 设备方法表驱动的人机接口今天写一个 Linux 网络驱动最关键的一步是填好struct net_device_ops这张回调表。它相当于驱动对外提供的人机接口内核网络协议栈在特定事件发生时会调用表中对应的函数指针。snull 的设备方法表大致是这样的static const struct net_device_ops snull_netdev_ops { .ndo_open snull_open, .ndo_stop snull_release, .ndo_start_xmit snull_tx, .ndo_ioctl snull_ioctl, .ndo_set_rx_mode snull_set_rx_mode, .ndo_change_mtu snull_change_mtu, .ndo_tx_timeout snull_tx_timeout, .ndo_get_stats snull_get_stats, };光看这张表你就能大致猜到驱动的生命周期走向。ndo_open是设备被ifconfig up时触发的起点ndo_stop是ifconfig down时的终点ndo_start_xmit是协议栈把数据包交给驱动的入口ndo_ioctl处理用户空间的私有命令ndo_change_mtu控制最大传输单元的变化ndo_get_stats向用户空间汇报收发包统计。snull 这套方法几乎把网络驱动该有的回调都覆盖了一遍。1.3 私有数据结构每个设备的控制块snull的所有方法都围绕一个私有数据结构展开通常叫struct snull_priv。alloc_netdev分配struct net_device时会额外多分配一块私有区驱动用netdev_priv(dev)拿到它。这块私有区就是网络驱动面向硬件的控制块。真实驱动里私有区会存放寄存器映射、自旋锁、统计信息、队列状态、DMA 描述符在 snull 里它存放模拟缓存、skb 指针和状态标志。这种私有区的思路非常重要因为它保证了多个网卡实例之间互不干扰。你在 snull 里看到的priv-data、priv-tx_skb、priv-status这些字段本质上就是在模拟真实硬件的多个状态。为什么一开始就要讲回调表和私有数据结构因为我见过很多新手一上来就钻进snull_tx的代码里结果看了半天也不知道这个函数是被谁调用、数据从哪来、释放归谁管。先建立设备方法的整体地图再看具体实现效率会高很多。2. 打开与关闭设备方法的入口和出口2.1 snull_open 到底做了什么设备被ifconfig up之后内核会调用ndo_open对应到 snull 里的snull_open。它的职责非常明确把设备从内核里一个可用的软件对象变成可以收发数据的状态。snull 的代码很简短核心逻辑大致是static int snull_open(struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); /* 模拟硬件初始化清零缓存打开接收中断 */ memset(priv-data, 0, SNULL_BUFSIZE); priv-status 0; priv-lock 0; priv-rx_intr 1; netif_start_queue(dev); return 0; }重点在最后一句netif_start_queue(dev)。它告诉协议栈这个设备的发送队列现在可以使用了。我见过不少初学者抄了 open 函数却漏掉这一句结果就是网卡 up 之后一发送就卡死因为上层一直以为发送队列是停止状态。snull_open 里没有分配 DMA buffer、没有 request_irq因为这些在模拟环境里不存在但启动队列这个动作在真实驱动里一步都不能省。2.2 snull_release停止队列的时机决定一切对应的关闭操作是snull_release它在ifconfig down时被调用。实现上就是和 open 相反先netif_stop_queue(dev)停止发送队列再把模拟状态清掉统计信息归零收尾工作做好。这里要特别强调顺序一定是先停队列再释放其它资源。因为一旦队列没停协议栈可能正拿着一个 skb 往ndo_start_xmit里塞你这边却把缓存和中断都关了轻则丢包重则直接空指针。这属于并发问题SMP 环境下尤其明显。snull 用priv-lock字段做简单模拟真实驱动则应该用自旋锁保护寄存器和队列状态。设备方法不是写完了就没事回调之间的时序关系才是驱动质量的真正分水岭。2.3 失败返回与资源回滚真实驱动里open 可能失败。典型情况是 DMA 缓冲区分配失败、中断申请失败、底层硬件初始化超时。snull 因为是纯软件模拟snull_open基本不会失败直接返回 0但你要知道规范open 失败时要返回负错误码比如-ENOMEM。还有一个很容易被忽略的细节如果 open 过程中已经调用了netif_start_queue后面又发现初始化失败必须在失败路径里调用netif_stop_queue把队列状态恢复回去。否则你留着一个队列已启动但设备根本没可用的脏状态给内核后续操作全乱套。这个经验在真实驱动里价值很高因为很多 bug 就是在 open 的资源申请分支里少了回滚逻辑。2.4 打开关闭与并发保护的思考snull_open和snull_release在单核环境里比较简单但 SMP 机器上ifconfig up的同时可能正在收包网络栈也可能正尝试往设备里塞数据。snull 样例虽然简单代码里也设计了priv-lock、priv-status这类标志提醒你设备状态不是单一变量而是需要同步保护的资源。真实驱动中我习惯给生命周期状态单独加一把锁open/release 修改状态时持锁中断处理里读取状态时也要持锁避免悬空指针。很多从 snull 学起的开发者一开始忽略这个问题到了真实硬件上才遇到rmmod 时系统崩溃之类的事故。其实预防手段并不复杂关键是要意识到设备方法之间的并发边界。3. 发送路径拆解skb 在 snull_tx 里的所有权转移3.1 入口与数据拷贝网络协议栈准备好一个struct sk_buff之后会调用ndo_start_xmit把它交给驱动。从这一刻起这个 skb 的所有权就归驱动了协议栈不再碰它由驱动决定何时释放。snull_tx 的第一步是把发送数据取出来static int snull_tx(struct sk_buff *skb, struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); priv-tx_skb skb; priv-tx_packetlen skb-len; memcpy(priv-data, skb-data, skb-len); /* 模拟硬件发送并触发完成中断 */ ... return NETDEV_TX_OK; }很多新手在这里会困惑为什么要把 skb 的数据 copy 到priv-data真实硬件里数据需要写入网卡的 DMA 描述符或者通过 FIFO 寄存器写出去snull 没有硬件所以用一个私有缓冲区代表硬件 FIFO。这个 copy 动作的意义是让发送路径的形态和真实驱动保持一致而不是直接dev_kfree_skb(skb)完事。理解这一点之后你再看真实网卡驱动的ndo_start_xmit会发现主干几乎是同一个套路。3.2 软件模拟的中断完成真实网卡发送完成后会产生中断驱动在中断处理里释放 skb、更新统计、唤醒排队等待的上层。snull 用snull_interrupt模拟这个过程它根据priv-status里的标志位决定接下来执行发送完成还是接收完成static void snull_interrupt(struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); if (priv-status SNULL_TX_INTR) snull_tx_done(dev); if (priv-status SNULL_RX_INTR) snull_rx(dev); priv-status 0; }snull_tx_done要做三件事释放 skb、累计发送统计、唤醒发送队列。我建议把这三件事当成固定模板记下来。很多驱动 bug 都出在中断处理里要么忘了释放 skb要么统计没更新要么用netif_start_queue而不是netif_wake_queue。snull 的模拟中断虽然没有任何硬件背景但它把中断处理的核心动作拆得很清楚。3.3 队列停止与唤醒流控的核心netif_stop_queue相当于给发送队列按了暂停键。什么时候按暂停当驱动发现自己暂时无法处理下一个 skb 时。snull 作为模拟设备基本不暂停所以snull_tx里通常直接返回NETDEV_TX_OK。但一个完整的真实驱动会在发送路径检查硬件 FIFO 是否满了、DMA 描述符是否用完了一旦发现现在收不下下一个包就调用netif_stop_queue同时仍然返回NETDEV_TX_OK表示这个包我接下了。等中断处理完成再调用netif_wake_queue解除暂停。netif_wake_queue和netif_start_queue的区别值得单独说wake 不仅把队列状态改成可用还会主动通知上层你可以继续塞包了start 只设置标志通常用于初始化阶段。我在调试真实驱动时踩过一个坑中断处理完只更新了状态没调用netif_wake_queue结果发送队列一直停着链路看起来就像死了但统计里没有任何错误。所以发送完成路径的黄金三步就是释放 skb、更新统计、唤醒队列一个都不能少。3.4 返回值与发送超时钩子ndo_start_xmit的返回值有讲究。正常情况返回NETDEV_TX_OK表示驱动接受了这个 skb。另一种返回值是NETDEV_TX_BUSY表示驱动现在忙请上层稍后再试此时驱动要保证不会重复进入ndo_start_xmit。snull 的教学版本里特意保留了一个特殊接口专门模拟发送超时的场景内核的看门狗机制如果发现一个 skb 交出去后迟迟没有完成中断就会调用ndo_tx_timeout。超时回调里要做的事通常是统计tx_errors重置硬件状态调用netif_wake_queue重新启动发送队列。这类超时路径在真实驱动里非常重要因为硬件故障或驱动 bug 都可能导致发送永久卡死没有看门狗和超时恢复机制网卡就废了。snull 用它模拟的是设备方法里每个异常分支都需要设计到位这个工程思想而不只是展示一个回调函数怎么写。4. 接收路径没有中断的网卡是怎么收到包的4.1 snull_rx 构造 skb 的完整步骤真实网卡收到数据后驱动要分配 skb、把数据从硬件缓存搬到 skb、填充协议字段再交给内核网络栈。snull 的snull_rx完整演示了这套流程static void snull_rx(struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); struct sk_buff *skb; skb dev_alloc_skb(priv-rx_packetlen 2); if (!skb) { priv-stats.rx_dropped; return; } skb_reserve(skb, 2); memcpy(skb_put(skb, priv-rx_packetlen), priv-data, priv-rx_packetlen); skb-dev dev; skb-protocol eth_type_trans(skb, dev); netif_rx(skb); }这里有个很经典的疑问为什么dev_alloc_skb(len 2)分配完还要skb_reserve(skb, 2)因为网络层喜欢让 IP 头对齐到 16 字节边界很多网卡驱动会在 skb 头部预留 2 字节提升 CPU 处理效率。虽然 snull 模拟的不是真实硬件但这段代码来自真实驱动的通用写法因此保留了这个细节。skb_put之前数据区只是预留的空洞只有skb_put调用了skb-len才算数memcpy才有意义。4.2 eth_type_trans 为什么不能省eth_type_trans会读取以太网头部的协议字段把skb-protocol设置成ETH_P_IP、ETH_P_ARP等值同时处理广播、组播标志最后返回协议类型。如果漏掉这一步上层协议栈根本不知道这个包该交给 IP、ARP 还是别的协议数据包会被当成未知协议直接丢掉。snull 虽然设置了IFF_NOARP基本不参与 ARP但它依然调用eth_type_trans就是为了让后续协议栈的处理流程完整可用。我在写虚拟网卡时也碰到过一个问题自己构造的skb-dev忘了赋值结果eth_type_trans拿到一个空设备指针直接崩溃。snull_rx里的赋值顺序是有讲究的先设skb-dev再调eth_type_trans最后netif_rx这个顺序不要乱。4.3 netif_rx 与 NAPI 的选择netif_rx是传统的接收方式在中断上下文里把 skb 放入 CPU 的输入队列由软中断net_rx_action后续处理。NAPI 则是现代驱动普遍使用的机制中断只做一件事把poll回调挂到设备的napi_struct上然后在轮询里批量取包减少中断次数、提升吞吐。snull 是教学代码走netif_rx这条简单路径方便理解驱动把包交给网络栈的那一瞬间发生了什么。真实驱动里除非是极低吞吐的设备一般建议直接上 NAPI。你可以在snull_rx的基础上进一步研究netif_rx_ni和napi_schedule的配合方式把中断收包改成 NAPI 轮询收包。理解了netif_rx的本意再去看 NAPI 会轻松很多。4.4 软件模拟中断的状态机snull 的接收中断不是硬件产生的。一个常见做法是在模块初始化时用timer_setup注册一个定时器定时器到期后把另一台虚拟设备发来的数据放进priv-data同时设置priv-status | SNULL_RX_INTR再调用snull_interrupt(dev)。看起来像发了个中断本质是软件轮询循环。这个设计对我理解真实中断帮助很大。真实网卡的收包中断来了之后你只做两件事读硬件状态寄存器根据状态位决定是走接收处理还是发送完成处理。snull 的status字段就是那个状态寄存器的替身。你不需要纠结中断为什么在这里触发而应该关注中断触发后驱动对 skb 做了什么。5. 杂项控制路径ioctl、MTU、多播与统计5.1 snull_ioctl私有命令怎么加用户空间的ioctl请求经过网络协议栈最终会到达ndo_ioctl回调。snull 处理私有命令时先检查cmd值数据通过ifr-ifr_data传递。因为它是教学代码通常只实现一个简单的枚举命令再根据返回值做区分。需要强调的是自定义 ioctl 处理用户传入的数据时必须做严格校验包括长度检查、权限检查不能直接信任用户空间指针。真实驱动里这类接口往往涉及寄存器读写和固件操作稍有不慎就会被恶意用户利用。snull 只是告诉你入口在哪安全边界要靠你自己补。5.2 change_mtu在边界上较真ndo_change_mtu处理用户修改 MTU 的请求。snull 的实现会对新值做上下限检查超出范围就返回-EINVAL。这里容易忽略的点是MTU 变化会影响的不只是帧长还有 skb 分配的缺省大小、底层硬件的分片逻辑、甚至队列深度配置。你在驱动里改了dev-mtu但硬件描述符还是按旧 MTU 配置的就会出现MTU 改完之后能收不能发这种诡异问题。snull 把 MTU 限制在小范围内配合固定大小的模拟缓存正是为了避开展开复杂的缓冲管理。真实驱动里change_mtu 回调通常要和硬件配置同步甚至需要先停设备再改参数这也是它不能简单随手写完的原因。5.3 多播和混杂模式ndo_set_rx_mode老内核叫ndo_set_multicast_list负责响应IFF_PROMISC和地址列表变化。snull 这类虚拟设备通常直接忽略但真实硬件需要更新多播过滤表、设置混杂模式寄存器。这个回调的触发时机是地址列表变化时不是每个包到的时候所以不要在收包路径里频繁调用它。比较好的做法是记录当前标志状态只有变化时才真正跟硬件交互避免无谓的寄存器写操作。snull 里这块代码很薄但它是理解网卡收包过滤机制的入口。5.4 get_stats别等内核帮你数包snull_get_stats返回struct net_device_stats *。统计数据的维护完全由驱动自己负责发送成功在snull_tx_done里加tx_packets和tx_bytes接收成功在snull_rx里加rx_packets和rx_bytes丢包、错误在对应路径累加。很多初学者以为内核会在某个环节统一算好统计实际完全不是这样。驱动不主动更新ifconfig里 RX/TX 计数就一直是零。反过来讲统计字段丰富与否直接决定了网络调试效率我排查丢包问题的时候第一件事就是分别看rx_packets、rx_dropped、rx_errors的变化趋势判断问题出在硬件、驱动还是协议栈。所以 snull 虽然只维护几个计数但这个自己动手数包的思路值得在真实驱动里彻底贯彻。6. 驱动的起与灭snull_setup、注册与卸载6.1 alloc_netdev 与 setup 回调snull 没有用alloc_etherdev而是用更通用的alloc_netdev因为它的初始化回调snull_setup里要自定义很多东西。核心逻辑大致是static void snull_setup(struct net_device *dev) { ether_setup(dev); dev-netdev_ops snull_netdev_ops; dev-mtu 1500; memcpy(dev-dev_addr, Snull0, 6); dev-flags | IFF_NOARP; }在较新内核里ether_setup已经被整合进alloc_etherdev但snull_setup这种写法直观地告诉你设备初始化回调要负责设置netdev_ops、MTU、MAC 地址、特性标志。alloc_netdev的第一个参数传的是sizeof(struct snull_priv)这样每个网卡实例都会附带独立的私有区互不干扰。这个私有数据随设备一起分配的模式在内核里非常常见理解它对写其他子系统驱动也有帮助。6.2 硬件地址与以太网头的模拟snull 的 MAC 地址就是六个字节例如S,n,u,l,l,0。它不是一个有效以太网 MAC但因为是虚拟设备且设置了IFF_NOARP不参与 ARP 解析所以没问题。它还自定义了header_ops比如在发送前构造一个小自定义头记录包长信息。这带来一个很好的学习点真实以太网设备使用eth_header_opssnull 用自己的模拟头二者在协议栈里的接口完全一致。理解了这一点你就明白header_ops的意义——驱动不一定非得是以太网设备只要实现相同的头建立/解析接口协议栈就能正常工作。换个角度想这也是为什么 Linux 网络设备框架能承载各种奇怪链路层协议的原因。6.3 register_netdevice 之后初始化完成后模块入口调用register_netdevice(dev)把设备注册进内核。注册成功以后网卡才会出现在ifconfig -a里。snull 通常初始化两个设备一个收包另一个发包二者通过软件通道互通数据。注册顺序也是有讲究的先注册的设备会更优先获得低编号的名称。如果注册失败必须立刻释放之前分配的内存。不要以为注册失败后内核会自动帮你清理alloc_netdev分配的对象那是不可能的。我在调试其它驱动时见过因为漏掉注册失败分支导致模块加载失败一次后系统网络栈里到处是残留 net_device 引用的状况。snull 的注册代码很短但对错误路径的处理逻辑是完整的值得照抄。6.4 卸载顺序与内存释放模块卸载要做的事先unregister_netdevice注销设备再free_netdev释放设备内存最后销毁定时器等模拟资源。顺序反了会怎样如果你先free_netdev但 net_device 还挂在全局网络链上内核 sysfs 和协议栈随时可能访问它一崩一个准。这里还有个细节卸载过程里如果设备是 up 状态unregister_netdevice会先触发ndo_stop回调把队列停掉、资源释放掉。也就是说snull_release不但会在ifconfig down时被调用也出现在卸载路径里。理解这个调用链你才能在设计设备方法时把资源释放写得能在两种场景下安全执行。snull 的卸载函数本质上就是网络驱动卸载的标准范式。7. 复现与调试把 snull 跑起来需要做的事7.1 编译模块的注意事项snull 是较早时期内核的示例拿它直接编译到现代内核经常会报错sk_buff的字段改了、init_timer改名成timer_setup、alloc_netdev参数顺序变了等等。我的建议是不要直接拿老源码生搬硬套而是照着设备方法的结构用当前内核 API 重写一份。在当今内核上你主要替换的地方包括timer_setup的用法、netdev_priv获取私有区的写法、dev_alloc_skb的调用方式。替换完成后编译重点看net_device_ops里的字段名是否能对上这通常是版本差异最容易暴露的地方。如果只是学习原理不追求真的编译运行至少要把方法名和回调对应关系看懂运行层面的细节反而没那么重要。7.2 验证收发的常用手段模块加载成功后给 snull0 配地址例如192.168.0.1/24给 snull1 配192.168.0.2/24然后在 snull0 上 ping snull1。如果发送接收路径实现正确应该能 ping 通。ping 不通时第一步永远看统计ifconfig snull0里的 RX/TX 计数是否变化。发送方向有计数、接收方向没计数问题大概率出在snull_rx或netif_rx路径两边都有计数但 ping 不通就要看 MTU、协议解析和自定义头是否一致多半是eth_type_trans或头部处理出问题。这个先看统计再定方向的二分法我从调试虚拟网卡一路用到真实网卡非常好使。7.3 三个容易踩的坑第一个坑是私有数据不完整。struct snull_priv的大小必须大于等于驱动实际访问的字段范围否则越界写会破坏内核堆。第二个坑是发送完成后忘记释放 skb连续发送几次后内存只升不降长时间运行必然崩。第三个坑是skb_put与skb_reserve顺序搞反或者memcpy长度超过模拟缓存数组因为 snull 缓存是固定大小的。排查这类问题时KASAN和slub_debug是很好的帮手能直接帮你定位越界发生在哪个设备方法里。snull 虽然代码量不大但把这些典型错误都过一遍你对 skb 内存管理的理解会扎实很多。快速验证内存是否泄漏也可以直接看/proc/slabinfo里skbuff_head_cache的变化趋势。最后再分享一点个人体会snull 这套示例让我印象最深的不是某个回调函数写了多少行代码而是它把网络驱动的契约讲透了——哪些资源由驱动管理哪些交给协议栈队列什么时候停、什么时候开skb 什么时候必须释放。曾经在调试一个真实网卡驱动的丢包问题时我回来翻 snull 的代码发现问题根源就是中断处理里漏了netif_wake_queue。所以说别觉得虚拟网卡示例不够硬核设备方法的正确性从来不是由硬件的复杂度决定的。
阅读完成 · 觉得有帮助?