☰
snull虚拟网卡驱动源码拆解:从net_device到中断模拟
2026/10/15 13:12:26 网站建设 项目流程

简介:虚拟网卡驱动snull.c原版源码来自《Linux Device Drivers》经典教材,是一套非常适合Linux内核/驱动开发学习者的入门与实践范例。该驱动利用软件模拟了真实网卡的数据收发、中断处理、NAPI轮询及发送超时等关键机制,并包含数据包缓冲池管理、校验和计算、收发统计等功能模块。资源包共7个文件,压缩后仅14KB,主要有snull.c源码、snull.h头文件、Makefile编译脚本,以及snull_load、snull_unload模块加载/卸载脚本和对应的.bak备份文件,便于直接编译、加载和对照学习。目前已有1576人学习下载该资源。读者可从中理解net_device、sk_buff、设备中断、NAPI等核心概念,掌握Linux网络驱动的基本框架与编写思路,还可通过修改代码实践发送超时模拟、动态MTU调整等细节,为后续开发真实网卡驱动或网络虚拟化设备打下基础。

1. 虚拟网卡驱动:没有硬件的网卡,一样能在内核里跑出真实链路

第一次拿到这份 snull 虚拟网卡驱动源码时,我心里是有点犯嘀咕的:一个连 PCI 设备都不注册的模块,怎么能让内核认为系统里多了一张网卡?跑起来之后才明白,这套代码就是给你演示「驱动的本质不是操作硬件,而是填充内核期望的那堆结构体和回调函数」。它不带任何真实硬件依赖,通过软件模拟中断和数据发送,让 sk_buff 走完从协议栈到驱动再到协议栈的完整环回,非常适合拿来理解 Linux 网络子系统的运行机制。当时我用一台没有任何物理网卡的虚拟机加载这个模块,ifconfig 里直接多出 sn0 和 sn1 两个接口,连 IP 都能配,telnet 自己都能通。这篇笔记就从这个「假网卡真链路」的源码包说起。

这份源码的价值不在于它能驱动某块具体的网卡,而在于它把虚拟设备驱动的骨架完整暴露在你面前:net_device 结构体怎么初始化、ndo_start_xmit 怎么被协议栈调用、软中断里怎么处理接收队列。我按「结构拆解 → 代码精读 → 跑通实验 → 二次改造」的顺序来写。想搞懂网络驱动又不想被硬件手册劝退的人,这份代码是性价比很高的入门材料,没有之一。

2. 整体设计与源码结构:snull 如何骗过内核协议栈

2.1 两个版本的设计差异:第一课与第二课的区别

这套源码包里其实包含两个递进版本,行内习惯叫「第一课」和「第二课」。第一课是最朴素的虚拟网卡:发送函数里直接调用接收函数的入口,数据包在驱动内部完成一次环回;第二课在中间加了中断模拟和收发队列,行为上更接近真实网卡的工作方式。我第一次读的时候,先看的是第一课,因为它的数据流向最直白:dev_queue_xmit触发ndo_start_xmit,在驱动内部直接把 skb 重新递交回netif_rx,等于网卡自己跟自己通信。而第二课引入了snull_rx、snull_interrupt和两个skb队列,让收发路径分离开,更像一块会异步发中断的硬件。

两版代码选哪个入手?我的建议是:只想搞懂「驱动最少要提供什么」就看第一课,它把协议栈到驱动的边界画得非常清晰;想继续深入「中断和队列在驱动里怎么模拟」就切到第二课。内核对真实网卡和虚拟网卡的要求是一视同仁的,所以两个版本里都要实现struct net_device_ops里的ndo_open、ndo_stop、ndo_start_xmit这几个关键回调,光这点就能劝退一批以为写驱动就是在操作寄存器的新手。

2.2 源码文件目录与每个文件的职责

拿到源码包之后,先别急着 make,先把文件列表过一遍。这套代码的文件排布很经典,我拆下来的目录结构大致是这样:

snull/ ├── snull.c # 主模块文件:初始化、清理、net_device 注册 ├── snull.h # 结构体定义、寄存器级数据结构、私有数据 └── Makefile # 编译入口,依赖内核头文件

snull.c承担了全部驱动逻辑:模块入口snull_init里调用snull_setup对net_device做初始化,然后register_netdev再到内核登记设备;snull.h里定义了两个虚拟设备的snull_priv结构,字段包括统计用的stats、锁lock、两个sk_buff_head队列等。Makefile 里如果目标内核版本变了,需要确认编译时引用的内核构建目录是否指向了当前运行的 kernel headers。

2.3 虚拟中断与时钟:没有硬件中断,就用内核定时器伪造一个

第二课里最耐看的细节是它如何处理「没有硬件」这件事。真实网卡收到数据会触发硬件中断,snull 没有中断线,就改用内核的timer_list:在snull_open里初始化一个高频率的定时器,每次到期就调用snull_interrupt,然后在中断处理函数里判断虚拟的「设备状态寄存器」是否置位,模拟中断到来。我当时读到这里拍了下大腿——原来驱动的骨架可以这样抽象,硬件状态完全用内存里的标志位代替。

这套做法给后续二次开发留了很大余地。比如你想模拟网卡断线、模拟丢包,根本不用碰物理设备,直接改snull_priv里的状态字段就行。学习时你甚至可以把这个定时器周期调大调小,观察不同频率下收包是否有差异。这也解释了为什么这套代码至今还在被反复讲解:它把一个黑盒般的「网卡」拆成了几个明明白白的软件机制。

3. 核心模块拆解:一个数据包从发送到接收的完整链路

3.1 net_device 初始化:驱动与内核的第一个约定

驱动要注册一张虚拟网卡,内核需要知道「你是谁、你能干什么」。这些信息全部装在一个struct net_device里。snull 通过snull_setup函数来填写这份「自我介绍」。我把关键步骤抽出来讲。

static void snull_setup(struct net_device *dev) { /* 使用内核提供的以太网初始化辅助函数 * 会填充 dev->header_ops、dev->type、dev->hard_header_len * 以及一组默认的以太网协议处理逻辑 */ ether_setup(dev); /* 指定驱动私有的数据结构长度 * 内核会在分配 net_device 时一并把私有区挂在 dev->priv 上 */ dev->priv_size = sizeof(struct snull_priv); /* 关键回调:打开/关闭设备、发送数据包 */ dev->netdev_ops = &snull_netdev_ops; /* 设备标志位: * IFF_NOARP 表示不需要 ARP 解析(点到点链路) * 注意真实以太网驱动一般不设这个标志 */ dev->flags = IFF_NOARP; }

用ether_setup是省事也容易误解的一步——它把设备伪装成标准的 Ethernet 设备,填充了 14 字节 MAC 头相关的默认行为。但 snull 接收逻辑里其实用不到真实的 ARP,所以IFF_NOARP把邻居子系统的负担卸掉了。priv_size是容易忽略的细节:驱动不在net_device里硬编码私有数据,而是告诉内核「多分配这么多字节」,再用netdev_priv(dev)取地址。这么做的好处是设备的生命周期由内核统一管理,不会出现手动 malloc 和 free 错位的问题。

netdev_ops是驱动与内核之间的函数表。snull 主要实现了ndo_open、ndo_stop、ndo_start_xmit这三位,其余没实现的操作内核会用默认行为兜底。对新手来说,理解这张函数表比看懂任何单一函数都重要——它定义了协议栈在什么时机调用驱动里的哪个函数。

3.2 发送路径:ndo_start_xmit 到底在忙什么

当用户进程通过 socket 发送数据,经过 TCP/IP 协议栈层层封装后,最终会调用驱动注册的ndo_start_xmit。snull 里的发送函数非常直白,它接管了 skb 后就进入驱动自己的处理流程。

static int snull_tx(struct sk_buff *skb, struct net_device *dev) { int len; char *data; struct snull_priv *priv = netdev_priv(dev); /* 记录发送流量的统计信息,ifconfig 里看到的 tx 字节数就来自这里 */ priv->stats.tx_bytes += skb->len; priv->stats.tx_packets++; /* 取出报文内容和长度,模拟硬件把数据搬进设备缓冲区 */ data = skb->data; len = skb->len; /* 发送完成后必须释放 skb,否则每发一个包就泄漏一块内存 * 真实驱动在这里通常会调用 DMA 映射相关的准备工作 */ dev_kfree_skb(skb); return 0; }

这段代码的职责边界非常清晰:协议栈把 skb 完整交给驱动后,驱动有两种选择——要么把数据真正写到硬件,要么像 snull 第一课那样直接在驱动内部递给接收路径。核心是dev_kfree_skb不能漏,否则驱动的发送路径每跑一次就丢一块内存。priv->stats是struct net_device_stats的实例,这里面记录的数据直接呈现在ifconfig里。

有一个很多新手都会问的点:为什么snull_tx返回 0 而不返回NETDEV_TX_OK?看内核源码你会发现NETDEV_TX_OK就是 0,写成 0 是历史遗留风格。但如果你在更现代的内核版本上编译这份代码,建议写成NETDEV_TX_OK,语义更明确。返回非零值意味着设备繁忙,协议栈会尝试重传,这对 snull 这种纯软件设备没有意义。

3.3 接收路径:从环回到中断模拟的变迁

第一课里发送完直接调用接收函数,这是一种「自产自销」的环回。第二课则构造了一个更接近真实硬件的异步流程:发送时数据先被放进一个队列,随后定时器「中断」触发,中断处理里从队列里取出 skb,再调用netif_rx上交给协议栈。下面这段对应第二课的接收核心:

static void snull_rx(struct net_device *dev) { struct sk_buff *skb; struct snull_priv *priv = netdev_priv(dev); /* 从接收队列取一个 skb;如果队列为空说明没有待处理报文 */ skb = skb_dequeue(&priv->rxq); if (!skb) return; /* 接收统计:ifconfig 里看到的 rx 计数器在这里累加 */ priv->stats.rx_bytes += skb->len; priv->stats.rx_packets++; /* 上报协议栈之前需要把 skb 的协议类型解析出来 * eth_type_trans 会根据以太网头部的 ethertype 字段填 skb->protocol */ skb->dev = dev; skb->protocol = eth_type_trans(skb, dev); /* 交给内核网络栈,此后 skb 的生命周期归协议栈管理 */ netif_rx(skb); }

eth_type_trans在这里是承上启下的一步。它读取 skb 数据区里的以太网头部,把skb->protocol设置成ETH_P_IP或ETH_P_ARP等值,这样协议栈才能决定把包送给 IP 层还是 ARP 层。对虚拟设备来说,这一步也顺便把 14 字节的以太网头从数据区头部剥离了。如果你想让 snull 支持 VLAN,可以在eth_type_trans之前自己解析 802.1Q 头,这是后话了。

3.4 中断模拟的实现细节:定时器怎么把队列里的包「顶」出来

netif_rx的调用被放在snull_interrupt中,而snull_interrupt又挂在timer_list回调上。我把这个「伪中断」的代码也摘出来看一眼:

static void snull_interrupt(unsigned long context) { struct net_device *dev = (struct net_device *)context; struct snull_priv *priv = netdev_priv(dev); /* 查看虚拟状态寄存器:如果接收队列非空,视为设备收到了数据 */ if (!skb_queue_empty(&priv->rxq)) { /* 驱动主动关闭中断,防止处理期间再次被调度 */ netif_stop_queue(dev); /* 执行实际的接收处理 */ snull_rx(dev); /* 重新开启发送队列,恢复传输能力 */ netif_wake_queue(dev); } /* 重置定时器,让中断按固定周期持续触发 */ mod_timer(&priv->timer, jiffies + 1); }

snull_interrupt里netif_stop_queue和netif_wake_queue的配对使用,模拟了真实驱动在中断处理上下文中短暂关闭队列、处理完再恢复的过程。这里mod_timer的周期是 1 个 jiffy,在 HZ=1000 的系统上就是 1 毫秒触发一次中断。你可以把jiffies + 1改成jiffies + 10,观察吞吐量会掉一个量级——这种实打实的实验比看十遍理论都管用。

4. 环境准备与运行验证:把 sn0 和 sn1 真实跑起来

4.1 编译环境与 Makefile 的适配

先把环境要求说清楚。这份源码不挑发行版,但内核头文件必须装好,同时需要 gcc 和 make。我习惯在 Ubuntu 系的系统上验证,用下面这组命令准备依赖:

sudo apt update sudo apt install build-essential linux-headers-$(uname -r)

编译驱动用的 Makefile 和普通用户态程序的写法差异很大。我常用的是内核模块的标准写法,关键在obj-m和目标内核的KDIR指向:

obj-m := snull.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) default: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

obj-m告诉 kbuild 系统要把snull.o编译成可加载模块。这里有一个容易踩的坑:如果你直接gcc -c snull.c,会得到一堆找不到头文件的错误,因为内核模块必须经过 kbuild 系统来编译,它会带着内核源码树里的头文件与编译参数一起工作。KDIR里的build是一个软链接,指向真正安装的内核头文件目录。M=$(PWD)指定了模块源码所在目录,kbuild 会在那个目录下生成snull.ko。

4.2 加载模块与创建虚拟网卡

编译完成后,snull.ko会出现在当前目录。加载前建议先记录一下当前网络状态,避免虚拟网卡和已有网络冲突。然后执行加载:

sudo insmod snull.ko dmesg | tail -20

insmod成功后,dmesg里能看到模块初始化时打印的注册信息。此时用ip link show查看,会发现多出两个网络接口。snull 的初始化代码里注册了两份net_device,一个叫sn0,另一个叫sn1。这两个接口在内部是有对应关系的:它们共享一个私有数据结构,sn1的发送队列指向sn0的接收队列,反之亦然——所以这两张「网卡」从诞生起就是相连的,类似一根交叉网线把两台机器背对背连起来。

如果dmesg里只看到一个设备,通常是内存分配失败或者register_netdev返回值没检查。源码里snull_init对每次注册都做了失败回滚,但如果你在精简版上二次开发,很容易漏掉这层保护。

4.3 配置 IP 并验证收发路径

给两张虚拟网卡配上 IP 之后,验证链路就是一条 ping 命令的事:

sudo ip addr add 192.168.0.1/24 dev sn0 sudo ip link set sn0 up sudo ip addr add 192.168.0.2/24 dev sn1 sudo ip link set sn1 up ping -I sn0 192.168.0.2

ping 能通,说明发送路径、接收路径、skb 传递和协议栈解析全都正常工作。这是我的习惯验证顺序:先ping -I sn0 192.168.0.2确认点对点通了,再用ifconfig sn0看RX packets和TX packets是否同步增长。如果 ping 不通,先别怀疑驱动,先用ip link show sn0确认接口状态不是 DOWN,再dmesg查内核有没有报错。

更进一步,可以用 tcpdump 在 sn0 上抓包,观察 ICMP 报文是否完整走了一次环回。在虚拟网卡上抓包和在物理网卡上没有区别,因为 snull 的 skb 会照常经过内核的 pcap 钩子,这从侧面印证了「驱动对内核来说和真实网卡没有本质差异」。

4.4 用 iperf 或 netperf 做简单的吞吐验证

如果只是验证「通不通」,ping 就够了;但如果你想观察驱动改动的效果,吞吐测试更合适。我常用 iperf3 做这个事,不过 sn0 和 sn1 在同一台机器上,走的是驱动内部环回,数字反映的是软件路径的性能上限,不要拿它和物理网卡对比。

# 终端A:在 sn1 上启动服务端 iperf3 -s -B 192.168.0.2 # 终端B:通过 sn0 发数据 iperf3 -c 192.168.0.2 -B 192.168.0.1 -t 10

跑完之后你会看到一个「诡异」的现象:带宽可能高得离谱,因为数据根本没离开内存,全程在驱动内部搬运。这是正常的结果,恰恰说明这套虚拟驱动的收发数据路径是完整且高效的。如果这里吞吐掉到极低,优先怀疑定时器中断频率——把mod_timer的周期调大后吞吐立刻下降,这是中断节奏影响收包能力的直接证据。

5. 避坑指南:编译、加载、收发包异常的高频排查

5.1 insmod 报错:模块版本与当前内核不匹配

现象:insmod snull.ko提示Invalid module format或者version magic mismatch。原因:内核模块和当前运行内核的版本、编译配置不一致。解决:重新编译前确认KDIR指向当前运行的 kernel build 目录,同时保证/lib/modules/$(uname -r)/build存在。如果升级了内核,务必重新 make。

这个错误在刚接触内核模块开发时几乎必然遇到。我最早遇到时,第一反应是代码写错了,折腾了半天才发现是头文件路径问题。现在的习惯是加载任何 .ko 之前先看一眼uname -r,心里有数再动手。

5.2 加载成功但 ifconfig 看不到 sn0

现象:insmod执行成功,dmesg没有任何输出,但ip link里看不到 sn0 和 sn1。原因:新版内核里register_netdev成功但设备处于 down 状态,或者驱动初始化时打印级别较低,dmesg被日志等级过滤。解决:先执行ip link show确认设备是否存在,再ip link set sn0 up手动拉起。如果依然看不到,就用dmesg -n 8调高日志级别后重新加载。

还有一类情况是模块加载了两次,第二次加载时sn0名字被占用。snull 源码里对设备名做了硬编码,重复加载同一模块前必须先rmmod snull,否则register_netdev会因为设备名冲突而失败。

5.3 ping 通但 RX/TX 计数器全零

现象:ping -I sn0 192.168.0.2能通,但ifconfig sn0显示RX packets: 0 TX packets: 0。原因:如果你看的是第二课版本,数据在被netif_rx递交后,计数是在软中断上下文中完成的,部分内核版本的ifconfig读取的统计来自dev->stats以外的路径。解决:在驱动里直接打印priv->stats,或者用cat /sys/class/net/sn0/statistics/rx_packets读内核维护的统计文件,两者对照。

这个问题我认真查过一次,最后发现是驱动代码里更新priv->stats的时机和ifconfig读取的数据源出现了错位,而不是数据没收到。以后再遇到类似「计数对不上」的驱动问题,我都先怀疑统计的更新入口,而不是先怀疑数据路径。

5.4 rmmod 时系统卡死或报忙

现象:执行rmmod snull无响应,或者提示Resource temporarily unavailable。原因:可能有进程还持有 sn0 或 sn1 的文件引用,比如 ping、tcpdump、iperf3 还没退出。解决:先杀掉残留的网络工具进程,确认接口 down 掉,再执行rmmod。如果仍然无法卸载,检查是否还有路由指向 sn0,用ip route flush dev sn0清理。

5.5 skb 内存泄漏:模块长时间运行后内存持续增长

现象:加载 snull 后,free -h里 used 内存随时间线性增长。原因:发送路径里dev_kfree_skb被跳过,或者接收队列没有在ndo_stop里清空。解决:在snull_stop中遍历rxq和txq,调用skb_dequeue配合kfree_skb释放所有残留报文。

这类问题在虚拟驱动上暴露得特别快,因为数据包全是软件产生的,发得勤快内存涨得就快。真实驱动的内存泄漏排查会更隐蔽,但思路完全一样:检查 skb 从进入到离开驱动的每一站,确认谁负责释放。

6. 二次开发:如何把 snull 改造成你想要的虚拟网卡

6.1 增加一个自定义统计节点:按方向统计丢包率

snull 自带的统计只有tx_packets、rx_packets这种基础计数。二次开发时可以引入定向丢包模拟:

static int snull_mac_stats(struct net_device *dev) { struct snull_priv *priv = netdev_priv(dev); /* 每接收 64 个包就模拟丢一个,用于验证上层 TCP 是否触发重传 */ if (priv->rx_packets % 64 == 0) { return 1; /* 模拟丢包 */ } return 0; }

改完之后,在发送函数里加一个丢包判断,观察 TCP 流的 cwnd 变化。这种实验在物理网卡上几乎没法做,但在 snull 上改几行就能看到完整的拥塞控制反馈,也是我非常推荐的第一个二开实验。

6.2 在收包路径上嵌入一个 hook:测量协议栈处理耗时

想在驱动边界测量「从进入驱动到 netif_rx 返回」的耗时,是驱动层性能分析的基础。可以用ktime_get_ns打时间戳:

static void snull_rx(struct net_device *dev) { u64 start, delta; struct sk_buff *skb; start = ktime_get_ns(); skb = skb_dequeue(&priv->rxq); if (!skb) return; skb->dev = dev; skb->protocol = eth_type_trans(skb, dev); /* 驱动内部处理结束,测量净处理时间(不含协议栈) */ delta = ktime_get_ns() - start; priv->last_rx_cost_ns = delta; netif_rx(skb); }

有了这个时间戳,你可以回答两个很实际的问题:驱动本身的收包开销是多少;如果后续在驱动里加了过滤逻辑,延迟增加了多少。这比单纯看吞吐数字更能定位瓶颈。

6.3 把一对网卡改造成任意拓扑:从点对点到多接口互连

我在做模拟项目 X 的时候,把 snull 扩展成了四接口版本。思路很简单:把snull_priv里的队列配对改成数组,建立一张端口映射表,让任意两个接口可以互发数据。这本质上是把一个「交换机的转发逻辑」塞进驱动里,配合 netfilter 钩子就能在无硬件的环境下搭出一张可编程的虚拟网络。

改造注意点是锁的问题。多接口并发收发时,两个 CPU 可能同时访问同一张映射表,snull 原本的单锁方案会在spin_lock上产生竞争。改为RCU读取、写时复制映射表是比较标准的解法,这也为后面引入per-cpu统计位留下了空间。

6.4 我的验证习惯:每个改动都跑一遍链路回环

每次改完 snull,我都会强制自己走一遍固定的验证清单:insmod后确认两个接口都出现,配置 IP 后 ping 通,tcpdump 抓到包,再跑一轮 iperf3 记录吞吐基线。改动如果涉及接收路径,我会额外用 4 个并发 ping 连续打 5 分钟,确认没有计数异常和内存增长。这套流程帮我抓出过不少「静态检查没问题、一跑就现原形」的代码问题。从那以后我验证任何网络驱动的改动,都强制走一遍回环测试,不再偷工减料。希望这份拆解对你跑通这套虚拟网卡驱动源码也有帮助。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询