☰
snull虚拟网卡驱动解析:从内核骨架到数据收发实践
2026/10/11 3:22:50 网站建设 项目流程

简介:《Linux 设备驱动程序》(LDD3) 第 14 章经典示例 snull 虚拟网卡驱动原版源码,适合 Linux 内核驱动开发者、网络协议栈学习者及驱动移植工程师。该驱动以纯软件方式创建 sn0、sn1 两个虚拟网络接口,数据包可在两者之间回环转发,完整演示了 net_device 注册、sk_buff 队列、中断与 NAPI 轮询收发、统计维护、超时恢复等关键机制。压缩包共 7 个文件、14KB,含主源码 snull.c、头文件 snull.h、加载/卸载脚本 snull_load 与 snull_unload、Makefile 及 2 个备份文件,便于在 2.6 内核环境编译加载验证。已有 1576 人学习下载。对想从零理解网卡驱动打开、发送、接收、释放全流程,并对照 LDD3 逐行研读内核网络接口代码的读者,这份原版代码是极具价值的入门与教学样例。

1. 虚拟网卡驱动源代码:为什么说 snull 是理解网络驱动的最佳入门样本

拿到网络驱动源码包,很多人第一反应是直接看网卡厂商的万行级驱动,结果被 DMA、中断亲和、固件加载这些外围事务淹没,三个月过去连ndo_start_xmit的调用时机都没摸清。snull 这个经典虚拟网卡驱动不一样,它用最小的代码量把「网络设备驱动到底在干什么」这件事交代清楚了:你 insmod 进去,系统会直接多出两个网口,两个口之间能 ping 通,整个数据链路不经过任何真实硬件,收发路径全在内存里模拟。这份源码适合三类人:刚啃完内核设备模型想找驱动练手的人、需要在没有硬件条件下验证协议栈行为的人、以及想快速读懂真实驱动骨架的人。它解决的问题很直接——用最少的外围机制,把网络驱动的主干逻辑暴露给你看。

2. 驱动骨架拆解:两个网口如何虚拟出一根网线

2.1 注册两个 net_device:模板驱动的设备模型

snull 核心机制是向内核注册两个net_device实例,分别对应snull0和snull1。这两个设备没有真实硬件,所以私有的数据结构格外重要。看这段原版源码里的设备初始化逻辑:

static struct net_device *snull_devs[2]; static void snull_setup(struct net_device *dev) { ether_setup(dev); /* 按以太网设备设置默认参数 */ dev->open = snull_open; /* 网口 up 时调用 */ dev->stop = snull_stop; /* 网口 down 时调用 */ dev->hard_start_xmit = snull_xmit; /* 发送入口 */ dev->hard_header = snull_header; /* 手动构造链路层头 */ dev->rebuild_header = snull_rebuild_header; /* ARP 处理入口 */ dev->set_mac_address = NULL; /* 不允许运行时改 MAC */ dev->flags |= IFF_NOARP; /* 标记为无 ARP 设备 */ }

逻辑上,snull_setup只是一个初始化回调,真正注册发生在后面的snull_init里,用alloc_netdev分配设备对象,再用register_netdev把设备交给内核网络栈。注意ether_setup(dev)这一步十分关键,它一口气设置了dev->type(ARPHRD_ETHER)、默认 MTU(1500)、hard_header_len(14 字节),如果不调它,后续ifconfig看到的设备类型和协议栈行为全部会跑偏。

参数层面,IFF_NOARP标记位意味着设备默认不发起 ARP 请求,两个虚拟接口之间通信时如果走 IP 协议,需要手动处理 ARP 请求——这正是原版驱动里snull_rebuild_header存在的意义。很多初学者读到这里会困惑:既然是模拟驱动,为什么还要管 ARP?答案很简单:IP 协议栈独立于驱动存在,你不接真实硬件,但发出去的 IP 包在协议栈眼里依然需要一个 MAC 头,这个头就得驱动自己造。

2.2 私有数据结构:用指针把两个接口连成一条链路

net_device本身只提供通用框架,真正把snull0和snull1关联起来的,是驱动自己定义的一个私有结构。这个设计是整个 snull 的灵魂:

struct snull_priv { struct net_device *dev; /* 本设备自身 */ struct net_device *peer; /* 对端设备指针 */ struct sk_buff_head skb_queue; /* 接收队列,模拟网线 */ spinlock_t lock; /* 队列保护锁 */ int status; /* 链路状态标记 */ };

每个网口在自己的私有数据里保存了一个指向对端设备的peer指针。snull0的peer指向snull1,snull1的peer指向snull0。当一个包从snull0发出时,驱动直接把skb挂到snull1的skb_queue上;反过来也一样。这一对指针加上一个sk_buff_head队列,就模拟了一根完整的物理链路,不需要任何锁之外的真实资源。

这种设计值得记下来:真实驱动里,peer就是网线另一头的交换机或对端网卡,skb_queue就是 DMA 环形缓冲区。真实驱动收包时会从硬件 FIFO 里把数据搬进sk_buff,而 snull 直接从队列尾部取,省掉了 DMA 这一层,结构上完全一致。看代码时把「队列」脑补成「环形缓冲区」,整个驱动模型立刻就和真实硬件对上了。

队列上还挂了一把spinlock_t lock,这是原版里容易被忽略但极其重要的细节。发送路径和接受路径可能运行在不同的 CPU 核心上,比如snull0在 CPU0 发送,snull1在 CPU1 收包,同时操作系统还有软中断在处理上层的接收,这些路径如果没有锁保护,队列很容易被并发踩坏。真实驱动里这个锁要么换成NAPI机制自带的保护,要么直接依赖硬件的环形缓冲区原子操作,SMP 并发问题是一样的。

2.3 数据通路:从发送到接收的完整一次穿越

发送路径是驱动设计的核心主线,原版里的snull_xmit承担了全部工作。剥离掉统计计数,看主干逻辑:

static int snull_xmit(struct sk_buff *skb, struct net_device *dev) { struct snull_priv *priv = netdev_priv(dev); struct net_device *peer = priv->peer; struct sk_buff *skb2; /* 克隆包,交给对端 */ /* 更新本端发送统计 */ priv->stats.tx_packets++; priv->stats.tx_bytes += skb->len; /* 创建拷贝并转交给对端设备 */ skb2 = skb_clone(skb, GFP_ATOMIC); skb_queue_tail(&netdev_priv(peer)->skb_queue, skb2); /* 通知协议栈释放原始 skb */ dev_kfree_skb(skb); return 0; }

这里有一个关键细节:为什么要skb_clone而不是直接把原始skb传给对端?因为对端接收后还要经过协议栈的上层处理,而原始skb此刻仍归发送方所有,协议栈在发送完成后会统一回收。如果直接把同一个指针传给对端,两个设备同时访问同一个skb,引用计数会混乱,最终导致内存损坏。克隆只拷贝sk_buff结构体和head指针,不拷贝数据区,内存开销很小,这是内核里常见的零拷贝共享手法。

接收侧对应的snull_rx在驱动自己的软中断或任务队列里运行,从队列里取出skb2,调用netif_rx上送给协议栈:

static void snull_rx(struct net_device *dev) { struct sk_buff *skb; struct sk_buff_head *queue = &netdev_priv(dev)->skb_queue; skb = skb_dequeue(queue); /* 从队列中取出一个包 */ if (!skb) return; skb->dev = dev; /* 指定收包设备 */ skb->protocol = eth_type_trans(skb, dev); /* 识别上层协议 */ netif_rx(skb); /* 交给内核协议栈 */ }

eth_type_trans是必须调用的,它从以太网头里读出协议类型(如 IP 是 0x0800),同时把skb->pkt_type设置成单播、广播或多播。发送端那一路克隆出来的包,走到这里才算真正进入了协议栈的接收路径。整个数据流程一句话概括:snull0把包克隆进snull1的队列,snull1从队列取出去做netif_rx,协议栈处理后 ping 包就能返回响应。

这里的队列机制在真实驱动里对应的是接收描述符环形缓冲区,收包动作对应 NAPI 的 poll 回调。读懂了这段,再看真实网卡驱动的ixgbe_poll或e1000_clean_rx_irq,你会发现只是数据结构更复杂,主干逻辑没有任何本质差别。

3. 编译与加载:从源码包到两个可 ping 通的网口

3.1 编译环境准备:内核头文件和 Makefile 的细节

拿到这份原版源码,头一件事不是看代码,而是确认编译环境对齐。snull 是面向 2.6 内核 API 时代的经典驱动模板,如果你所在宿主机内核版本较新,编译大概率会先遇到几个 API 变更错位。原版包里的 Makefile 结构通常是:

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

这个 Makefile 依赖宿主机的内核构建目录,也就是/lib/modules/$(uname -r)/build。运行make之前先确认目录存在:

ls -d /lib/modules/$(uname -r)/build # 如果目录不存在,需要先安装内核头文件包:kernel-devel 或 linux-headers

常见做法是 Debian/Ubuntu 系安装linux-headers-$(uname -r)包,RHEL 系安装kernel-devel。有些发行版的/lib/modules/$(uname -r)/build是一个软链接,指向/usr/src/linux-headers-...,软链接断了会导致编译报一堆找不到generated/autoconf.h的错误。编译失败的排障顺序:先查头文件包,再检查链接是否指向有效目录。

3.2 编译时最常见的 API 错位问题

源码包是原版,但内核 API 经历了多轮演进。2.6 时代传统的dev->hard_start_xmit接口在新内核里已被ndo_start_xmit取代,直接编译大概率报错。我一般会做一次小范围适配,改动点如下:

/* 新旧接口映射 */ static int snull_xmit(struct sk_buff *skb, struct net_device *dev); /* 新内核使用 net_device_ops 结构体 */ static const struct net_device_ops snull_netdev_ops = { .ndo_open = snull_open, .ndo_stop = snull_stop, .ndo_start_xmit = snull_xmit, }; /* 在 snull_setup 中注册 ops 替代直接赋值 */ void snull_setup(struct net_device *dev) { ether_setup(dev); dev->netdev_ops = &snull_netdev_ops; }

改动逻辑:旧内核把open/stop/xmit这些回调直接挂在net_device结构体上,新内核统一收拢到net_device_ops中。适配时不需要改动任何收发逻辑,只是把函数指针挪个位置。alloc_netdev的分配参数也从旧版的alloc_netdev(sizeof(struct snull_priv), "snull%d", snull_setup)演化为新版的alloc_netdev(sizeof(struct snull_priv), "snull%d", NET_NAME_UNKNOWN, snull_setup),多了一个name_assign_type参数,填NET_NAME_UNKNOWN即可。

另一个高频坑是dev_alloc_skb在新内核改名成了netdev_alloc_skb。如果你看到编译报错 "implicit declaration of function dev_alloc_skb",直接全局替换即可。这些 API 错位问题在真实世界中几乎一定会遇到,适配它们本身就是一次驱动移植练习。

3.3 加载驱动并验证:用 ping 证明数据链路通了

驱动编译好后,加载与配置流程如下:

# 加载模块 sudo insmod snull.ko # 检查系统日志,确认注册成功 dmesg | tail -20 # 期望能看到 snull0 和 snull1 两个设备注册的信息 # 查看新出现的两个网口 ifconfig -a | grep snull # 配置 IP 并激活 sudo ifconfig snull0 192.168.0.1 netmask 255.255.255.0 up sudo ifconfig snull1 192.168.0.2 netmask 255.255.255.0 up

这时候从snull0pingsnull1才能通。配置两个网口不要只配一个,因为包走到对端后,对端设备需要知道自己所在的子网地址才能回包。两个接口属于同一子网,协议栈以为它们是两台不同机器,实际上数据只在内存里转了一圈。

验证的进阶手法是同时开三个终端,一个tcpdump -i snull0,一个tcpdump -i snull1,一个ping -c 5 192.168.0.2,你能直观看到 ICMP 请求从snull0上发出、在snull1上被收到、响应包反向再来一次。这种双向可见的数据流验证在真实驱动调试中很难做到,因为物理网卡的收发包你只能用抓包器看一份,而这里能同时从两端视角看到同一个包。

4. 参数边界与行为模拟:MTU、并发和统计的隐藏细节

4.1 MTU 边界与 headroom:为什么小于帧长就有问题

ether_setup默认把网口 MTU 设成 1500。实际操作中如果把 MTU 调大,比如改成 9000(模仿巨型帧),两件事必须同时确认:一是sk_buff分配的线性数据区是否够大,二是接收队列里的skb是不是按新 MTU 分配的。原版驱动的接收侧队列只在初始化时预分配了一批固定大小的skb,这个大小对应默认 MTU。

# 把 MTU 调大后再跑大包测试 sudo ifconfig snull0 mtu 9000 up ping -s 8000 -M do 192.168.0.2

如果连队列里的skb都还是 1500 字节缓冲,发送 8000 字节的包到对端时,对端从队列取出的skb数据区根本装不下,包会在驱动层被截断。协议栈里的dev_queue_xmit不会帮你先分片,因为分片动作发生在 IP 层,只对真实出口生效。所以在这个模拟驱动里,改 MTU 必须同步改接收队列的缓冲大小,这一步原版源码并没有提供模块参数,需要自己动手加。

4.2 模拟丢包与延迟:加一个硬编码概率分支

snull 的传输延迟几乎为零,因为包从一个队列挪到另一个队列只是几个指针操作。想让它更贴近真实链路,常见做法是在snull_xmit里加延迟或丢包逻辑。丢包注入最简单:

/* 丢包注入:每 10 个包丢掉 1 个 */ static int drop_counter; static int snull_xmit(struct sk_buff *skb, struct net_device *dev) { struct snull_priv *priv = netdev_priv(dev); if ((++drop_counter % 10) == 0) { priv->stats.tx_dropped++; dev_kfree_skb(skb); return 0; /* 假装发送成功,协议栈不会重试 */ } /* 后续正常发送逻辑 */ ... }

注意return 0表示驱动已经接管了skb并成功发送,协议栈不会重试。丢掉的包只能靠上层协议(TCP 重传或应用层重试)来恢复,这正好模拟了真实链路随机丢包行为。延迟注入更简单,在接收路径里用ndelay或udelay空转一会儿,但如果是 SMP 环境,忙等待会占用 CPU,应该改用msleep或者schedule_timeout,只是那样收发就不在一个上下文里了,队列并发保护也要跟着改。

4.3 统计计数器:驱动层的健康指标

原版驱动对统计的处理比真实驱动简洁得多。发送侧在snull_xmit里更新tx_packets和tx_bytes,接收侧在snull_rx里更新rx_packets和rx_bytes。但要注意:如果只更新自己这一端的发送统计,不更新对端的接收统计,ifconfig snull1看到的 RX 数据永远是 0,这会误导调试。

/* 正确做法:各自管理自己的收发统计 */ static void snull_rx(struct net_device *dev) { struct snull_priv *priv = netdev_priv(dev); priv->stats.rx_packets++; priv->stats.rx_bytes += skb->len; ... }

ifconfig里的RX packets和TX packets全部来自net_device_stats,没有及时更新会让协议栈流量监控完全失真。这个问题在真实驱动里同样常见:网卡收包后中断处理函数忘记更新rx_packets,结果排查丢包时看到计数器纹丝不动,浪费一整天。看了这份源码,你应该形成肌肉记忆——每一条收发路径都必须同步更新统计。

5. 避坑指南:snull 移植与实践中的五个常见问题

5.1 insmod 成功但 ifconfig -a 看不到设备

现象:insmod snull.ko返回成功,dmesg里没有报错,但ifconfig -a看不到snull0或snull1。

原因:新版本内核的register_netdev对设备名和 MAC 地址有严格校验,如果设备名已被占用或 MAC 地址全 0,注册会被静默拒绝。另外,系统里如果已经加载过一次驱动,第二次 insmod 时同名设备注册会冲突。

解决:先执行rmmod snull,确认没有残留;再用ip link show查看是否有同名设备残留在别的命名空间里。检查dmesg | grep snull里是否有 "register_netdev failed" 之类的隐藏信息。如果 MAC 全 0,可以在snull_setup里手动分配一个合法的本地管理 MAC 地址,这是最常见的原因。

5.2 两个网口配置好 IP 后 ping 不通

现象:snull0和snull1都设好 IP,ifconfig状态正常,但ping 192.168.0.2100% 丢包。

原因:看一眼抓包结果,通常是 ARP 请求未得到响应。原版驱动在snull_header里为每个包手动构造以太网头,但因为设备被标记为IFF_NOARP,协议栈认为不需要 ARP,导致对端的 MAC 地址没有被解析。本质问题是snull_rebuild_header实现里直接调用arp_send的回包路径不完整。

解决:要么去掉IFF_NOARP让标准 ARP 流程参与,要么手动在对端arp表里添加静态条目:

# 在 snull1 上把 snull0 的 MAC 地址静态写入 ARP 表 sudo arp -s 192.168.0.1 00:11:22:33:44:55

如果两个网口要求完全自动通信,建议选择前者:去掉IFF_NOARP标志,让内核标准 ARP 模块处理,驱动只负责在包头里填入已知的 MAC,不要去实现半吊子的手动 ARP 逻辑。

5.3 并发 ping 大包时丢包或内核报错

现象:ping -f -s 1000高压测试时,终端报 "kernel BUG at net/core/skbuff.c" 或出现丢包。

原因:snull_xmit里skb_queue_tail操作只锁住了对端的队列,但克隆skb本身在发送端被释放时,如果协议栈还在引用原始skb,会触发引用计数异常。此外skb_queue_tail用spin_lock_bh还是spin_lock也影响并发表现,中断上下文打断锁保护区域会产生死锁风险。

解决:发送侧对队列操作一律用spin_lock_bh,把软中断关掉;克隆失败时不能直接丢掉原始skb,要返回NETDEV_TX_BUSY让协议栈稍后重发。另外,skb_clone的GFP_ATOMIC标志必须保留,因为发送路径在原子上下文(持有锁或软中断)中执行。

5.4 新内核上编译大量报错:函数名找不到

现象:error: implicit declaration of function 'dev_alloc_skb',或 undefined reference to 'init_net' 等。

原因:snull 原版基于 2.6 时代 API,现代内核 5.x/6.x 已经移除了部分旧接口。init_net这个全局变量在某些配置下被隔离到了不同头文件,dev_alloc_skb改名为netdev_alloc_skb,hard_start_xmit回调也被net_device_ops取代。

解决:按第 3.2 节的适配思路逐项替换。把原版内容视为一份「驱动逻辑设计稿」,API 层全部向新内核看齐。注意alloc_netdev的参数变化,旧版第一个参数是sizeof(struct snull_priv),新版在设备初始化后要用netdev_priv(dev)访问私有数据,偏移计算方式变了,不要在适配时顺手把netdev_priv写成dev->priv,新版直接访问priv字段编译都过不去。

5.5 设备上报错 "eth0: link down" 后无法恢复

现象:执行ifconfig snull0 down后再up,设备状态一直显示NO-CARRIER,ping 不通。

原因:驱动的snull_stop在 down 时把队列清空,但没有重置私有状态里的status标志位。重新 up 时snull_open检测到状态异常,直接放弃设备初始化。

解决:在snull_stop里把私有数据的status置为 0,并在snull_open里重新初始化skb_queue——清掉残留 skb,重置计数器。这是驱动常见 bug:停设备做了清理,但清理不彻底,恢复时旧状态残留导致启不来。调试时多看一眼dmesg,snull_open里最好加上printk输出链路状态,不要靠猜。

6. 进阶改造:把 snull 变成你自己的虚拟网络实验平台

6.1 增加一个可配置的丢包延迟参数模块

原版的问题之一是参数写死,改造方向是模块参数化:

static int snull_loss = 0; /* 丢包率 0-100 */ static int snull_delay = 0; /* 延迟微秒数 */ module_param(snull_loss, int, 0644); module_param(snull_delay, int, 0644); static int snull_xmit(struct sk_buff *skb, struct net_device *dev) { if (snull_loss > 0 && prandom_u32() % 100 < snull_loss) { dev_kfree_skb(skb); return 0; } /* 延迟模拟需在对端接收侧处理,发送侧不便 sleep */ ... }

加载方式变成insmod snull.ko snull_loss=10 snull_delay=5。有了参数化之后,你可以在用户态通过sysfs动态调整丢包率,echo 20 > /sys/module/snull/parameters/snull_loss,让虚拟链路模拟从无损到 20% 丢包的劣化过程——这在测试 TCP 拥塞控制算法时非常实用。

6.2 结合网络命名空间做双端测试

snull 原版在 root 网络命名空间里跑,两个网口都在同一个协议栈视角下,很多行为看不出隔离效果。更进阶的用法是结合网络命名空间:

# 建立两个网络命名空间 sudo ip netns add ns1 sudo ip netns add ns2 # 把 snull0 和 snull1 分别移入不同命名空间 sudo ip link set snull0 netns ns1 sudo ip link set snull1 netns ns2 # 在两个命名空间内配置 IP sudo ip netns exec ns1 ifconfig snull0 10.0.0.1 up sudo ip netns exec ns2 ifconfig snull1 10.0.0.2 up

注意把设备移入命名空间后,原命名空间里ifconfig立刻看不到这个设备,因为设备归属已经切换。两个命名空间之间的通信依然走的是同一个虚拟链路,相当于在主机上搭了一个完全隔离的虚拟网络环境。这个思路适合在后面叠加tc netem做延迟抖动模拟:

# 在 snull0 上叠加 20ms 延迟 + 10% 丢包 sudo ip netns exec ns1 tc qdisc add dev snull0 root netem delay 20ms loss 10%

从那以后,我拿到任何虚拟网卡类源码,都会先按这个顺序过一遍:编译适配走通整个加载流程,配两个网口验证双向通信,压低 MTU 和增加并发压力测边界,最后再加丢包延迟参数做场景模拟。这四步走完,对驱动每一行的理解深度和只读代码完全不一样。希望这份 snull 源码的拆解能帮你在网络驱动的入口处少走弯路,把虚拟设备当作真实硬件的替身,动手测出自己的判断力。

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

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

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

立即咨询