ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux虚拟网卡snull驱动源码拆解:net_device与skb收发闭环

Linux虚拟网卡snull驱动源码拆解:net_device与skb收发闭环

简介:面向Linux网络驱动入门与内核模块实践的snull虚拟网卡驱动源代码(原版),针对无真实硬件的双网卡回环收发场景,帮助读者理解网络设备注册、设备开闭、报文发送接收、中断处理与协议栈衔接等核心流程,适合驱动课程实验、源码阅读和二次改造。压缩包共7个文件、约14KB,含C源码、头文件、Makefile编译脚本、装载与卸载脚本,以及2个备份文件,既方便直接编译验证,也便于对照原版与修改版本。已有1579人学习。代码覆盖设备初始化、发送超时、统计信息、输入输出控制、最大传输单元调整、数据包池、套接字缓冲区封装和轮询机制等环节,能帮助读者掌握虚拟网卡从模块加载、注册到数据收发的完整链路,并对网络协议栈与设备驱动模型形成直观认识,适合作为内核网络驱动入门的可运行样本。

1. 一块“自己发、自己对端收”的虚拟网卡,为什么值得从头拆一遍

拿到这份虚拟网卡驱动源代码,我第一反应是:snull 这种“自己发、对端收”的极简写法,放到今天的 networks 栈里还能不能跑通。它不是玩具,而是一块完整的虚拟网卡实现——有 net_device 注册、有 open/stop、有发送队列、有接收投递、有中断模拟,只是把物理链路换成了内存拷贝。适合三类人:想搞明白内核网络栈从 dev_queue_xmit 到 netif_rx 到底走了哪几步的驱动新手;要给自家协议栈搭一个可控丢包、可控延迟测试床的老手;以及手里有这份源码、编译一次报二十个错就想放弃的人。它最大的价值是把“网卡”这个黑匣子拆成几百行可读的 C,而不需要任何硬件。

2. 拆骨架:snull 用一个 net_device 数组撑起整条收发链

先别急着编译。这份源码之所以值得逐行看,是因为它把网络驱动最核心的三件事压进了同一个文件:设备怎么注册、包怎么发出去、包怎么交回协议栈。看不懂这三件事,后面编译报什么错都只能靠猜。

2.1 从 module_init 到 register_netdev:8 个设备是怎么来的

源码里有一个固定长度的设备指针数组,长度定义成一个宏,常见值是 8。模块加载时循环 8 次,每次alloc_netdev申请一个 net_device 加一段私有数据,再用register_netdev挂进网络子系统。名字模板是sn%d,所以最终你会看到 sn0 到 sn7 八个接口。

关键在于:这 8 个设备里只有前两个是“接了网线”的。snull 的传输方案叫 trivial,意思是不做任何真实寻址——从 sn0 发出去的包,一定从 sn1 收到;从 sn1 发的,一定从 sn0 收到。剩下 6 个纯粹是为了演示“多设备注册”的写法,它们能 up、能配地址,但发不出任何东西。

/* 模块加载:循环注册,失败要回滚已注册的部分 */ static int __init snull_init_module(void) { int i, ret; for (i = 0; i < SNULL_MAXDEV; i++) { struct net_device *dev; /* 第二参数是名字模板,第三参数在新内核里是名字分配策略 */ dev = alloc_netdev(sizeof(struct snull_priv), "sn%d", NET_NAME_ENUM, snull_init); if (!dev) { ret = -ENOMEM; goto out_unreg; } snull_devs[i] = dev; ret = register_netdev(dev); if (ret) { free_netdev(dev); snull_devs[i] = NULL; goto out_unreg; } } return 0; out_unreg: /* 回滚:只注销已经注册成功的,顺序必须和注册相反 */ while (--i >= 0) { if (snull_devs[i]) { unregister_netdev(snull_devs[i]); free_netdev(snull_devs[i]); snull_devs[i] = NULL; } } return ret; }

这段代码有两个地方容易写错。一是alloc_netdev的第三个参数,老内核根本没有这个参数,直接三个实参;新内核少了它会编译不过。二是回滚循环的下标:register_netdev失败时 i 指向的是当前失败的这一项,while (--i >= 0)正好跳过它,从上一个已注册的开始注销,这个细节照抄就行,自己改很容易越界。

snull_init这个回调是每个设备注册时被调一次的地方,它决定了这块网卡“长什么样”。里面先调ether_setup(dev)把以太网该有的字段一次性填好——type、addr_len、mtu、hard_header_len全在里面,不调的话上层协议栈根本认不出这是一块以太网卡。然后挂netdev_ops、设watchdog_timeo、决定要不要开 ARP、分配 MAC。

2.2 snull_priv:一块虚拟网卡必须自己记录的四类状态

私有数据结构是这份源码里信息密度最高的部分。抛开教学用的花活,真正必须自己记录的东西只有四类:

状态类别典型字段为什么必须自己存
统计计数rx_packets、rx_bytes、tx_packets、tx_dropped上层ip -s link和/proc/net/dev全靠它
缓冲区池预分配的 packet 链表收发路径里不能随便 kmalloc,得有个上限
并发保护spinlock发送在进程上下文、接收可能在软中断,必须互斥
模拟硬件定时器、中断使能标志用来伪造“硬件来了一个包”这个事件
#define SNULL_PKTSIZE 1546 /* 以太网帧上限再加点余量,够放一个最大帧 */ struct snull_packet { struct snull_packet *next; struct net_device *dev; /* 这个包是从哪块卡发出来的 */ int datalen; u8 data[SNULL_PKTSIZE]; }; struct snull_priv { struct net_device_stats stats; /* 老接口用它,新内核建议只留计数器 */ struct snull_packet *ppool; /* 空闲池 */ struct snull_packet *rx_queue; /* 待处理的接收队列 */ int rx_int_enabled; spinlock_t lock; struct timer_list timer; /* 模拟硬件中断 */ };

池子的大小、每个包缓冲区的大小,都直接决定了这块虚拟网卡能扛住多大的突发。池子取太小,snull_tx里取不到包就丢,你会看到tx_dropped一路涨却找不到原因;取太大,模块加载时那一次连续分配就可能失败。我一般按“同时在线包数 × 最大帧长”估,教学场景下几十个包足够了。

2.3 发送与接收的闭环:snull_tx → snull_hw_tx → snull_rx

整份源码最值得反复看的就是这条调用链。上层协议栈调用dev_queue_xmit,最终落到驱动注册的发送函数;发送函数把数据搬进自己池子里的缓冲区;然后直接调用接收函数,把缓冲区“交给”对端设备;接收函数再申请一个 skb,把数据灌进去,最后用netif_rx还给协议栈。

/* 发送入口:由网络子系统在软中断或进程上下文调用 */ static netdev_tx_t snull_tx(struct sk_buff *skb, struct net_device *dev) { struct snull_priv *priv = netdev_priv(dev); /* 比以太网头还短的包,硬件也发不出去,直接丢并记账 */ if (skb->len < ETH_HLEN) goto drop; spin_lock(&priv->lock); snull_hw_tx(skb->data, skb->len, dev); /* 真正“发”出去 */ spin_unlock(&priv->lock); priv->stats.tx_packets++; priv->stats.tx_bytes += skb->len; dev_kfree_skb(skb); /* skb 归驱动所有,必须自己释放 */ return NETDEV_TX_OK; drop: priv->stats.tx_dropped++; dev_kfree_skb(skb); return NETDEV_TX_OK; }

snull_tx有三个约束必须记住。第一,skb 的所有权转移给了驱动,无论发成功还是丢弃,都必须dev_kfree_skb,漏一次就是稳定泄漏。第二,返回值是新内核的netdev_tx_t,恒返回NETDEV_TX_OK表示“我已经处理完了,队列可以继续”;返回NETDEV_TX_BUSY表示“队列满了,你重试”,但那条路要求驱动自己保证 skb 不被释放,很容易翻车,教学驱动没必要碰。第三,skb->len < ETH_HLEN这个判断不是摆设,协议栈偶尔会传短包下来,不判就会在读头部时越界。

/* 伪硬件:把数据搬进池子里的包,然后直接送给对端设备 */ static void snull_hw_tx(char *buf, int len, struct net_device *dev) { struct net_device *dest; struct snull_packet *packet; if (len < ETH_HLEN) return; /* trivial 方案的核心:sn0 的包给 sn1,其余设备的包给 sn0 */ dest = (dev == snull_devs[0]) ? snull_devs[1] : snull_devs[0]; packet = snull_alloc_packet(dest); if (!packet) return; /* 池子空了,静默丢弃,模拟链路拥塞 */ packet->datalen = len; memcpy(packet->data, buf, len); snull_rx(packet, dest); /* 同步投递,等价于“对端收到了” */ }

注意这里dest的取值逻辑。源码里的 trivial 方案只保证 sn0 和 sn1 互通,sn2 到 sn7 收到的包全都会跑到 sn0 去——因为条件判断只区分“是不是 sn0”。这是个教学简化,不是 bug,但如果你拿它做多设备测试就会一头雾水。要做多对多,得自己维护一张“谁连谁”的表。

/* 接收:申请 skb、灌数据、剥以太头、交回协议栈 */ static void snull_rx(struct snull_packet *pkt, struct net_device *dev) { struct snull_priv *priv = netdev_priv(dev); struct sk_buff *skb; skb = dev_alloc_skb(pkt->datalen + 2); if (!skb) { priv->stats.rx_dropped++; goto out; } skb_reserve(skb, 2); /* 让 IP 头四字节对齐 */ skb_put_data(skb, pkt->data, pkt->datalen); skb->dev = dev; skb->protocol = eth_type_trans(skb, dev); /* 剥掉 14 字节以太头 */ skb->ip_summed = CHECKSUM_UNNECESSARY; /* 虚拟链路,校验和必定正确 */ netif_rx(skb); priv->stats.rx_packets++; priv->stats.rx_bytes += pkt->datalen; out: snull_release_packet(pkt); /* 无论成败都要还回池子 */ }

snull_rx里三个动作缺一不可。skb_reserve(skb, 2)是为了让后面协议栈解析 IP 头时拿到四字节对齐的地址,某些架构上不对齐会直接触发异常,这个 2 是老规矩。eth_type_trans不只是读协议号,它还会把skb_pull掉以太头、设置skb->mac_header,不调的话上层会把以太头当成 IP 头解析,表现是“抓包能看到包,但协议栈说长度非法”。netif_rx是把 skb 挂到当前 CPU 的 backlog 队列,真正的协议栈处理在软中断里发生,所以它不阻塞。

2.4 中断与定时器:为什么教学驱动非要模拟一个“硬件事件”

真实网卡靠中断通知驱动“有包来了”,snull 没有硬件,就用一个定时器定期触发,回调里把rx_queue上的包挨个交给snull_rx。这个设计看起来多余,其实是为了让你在同一份代码里看到中断上下文和进程上下文的区别——定时器回调不是硬中断,早期内核在这里必须用netif_rx_ni而不是netif_rx,否则可能出现“软中断嵌套自己”的死锁告警。新内核把netif_rx_ni并掉了,统一用netif_rx,但你在老版本上照抄新代码就会编译不过。

/* 定时器回调:模拟硬件中断,把待收队列里的包投递上去 */ static void snull_timer(struct timer_list *t) { struct snull_priv *priv = from_timer(priv, t, timer); struct net_device *dev = priv->dev; struct snull_packet *pkt; unsigned long flags; spin_lock_irqsave(&priv->lock, flags); while ((pkt = priv->rx_queue) != NULL) { priv->rx_queue = pkt->next; snull_rx(pkt, dev); /* 逐个投递 */ } spin_unlock_irqrestore(&priv->lock, flags); /* 重新装填,形成周期性的“中断” */ if (priv->rx_int_enabled) mod_timer(&priv->timer, jiffies + 1); }

spin_lock_irqsave在这里不是可以省略的优化。发送路径在进程上下文拿锁,定时器回调可能在软中断上下文抢同一把锁,不用 irqsave 版本就有死锁风险。这类问题在内核里是典型的玄学故障:平时跑一万次都没事,压力一上来整机卡死,日志里什么都看不到。

3. 编译与跑通:从源码目录到 counters 真的在涨

源码读懂了不代表能跑。这份代码的原版目标是老内核,直接丢进新内核几乎必然编译失败。我的做法是先让它在新内核上编译通过、跑起来,再回头对照原版看差异——这样每一步的错误都是可控的、可定位的。

3.1 源码落位与 Kbuild

不要把驱动塞进内核源码树。建一个独立目录,把snull.c、snull.h放进去,配一个两行的 Makefile 就够了。

# 注意:下面每个命令行必须用 Tab 开头,空格会报 "missing separator" obj-m := snull.o KDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

obj-m表示编译成可加载模块,M=$(PWD)告诉内核构建系统“只编译这个外部目录”。KDIR指向当前运行内核的头文件目录,如果换了内核没换头文件,编译能过但 insmod 会报版本不匹配。第一次编译前先确认/lib/modules/$(uname -r)/build这个软链接存在,不存在就说明只装了内核没装开发包。

Makefile 里的 Tab 是血泪经验。编辑器自动把 Tab 转成空格、复制粘贴时被吞掉、Git 的 autocrlf 动了行尾,都会让 make 报一个和驱动代码毫无关系的missing separator,新手很容易在这里怀疑人生。

3.2 加载、看 dmesg、把 sn0 拉起来

编译产物是snull.ko。加载之前先看一眼modinfo,确认 vermagic 和你当前内核一致。

sudo insmod snull.ko dmesg | tail -n 20 # 确认没有 oops,也没有 register 失败 ip link show | grep -E '^\s*[0-9]+: sn' # 应该看到 sn0 到 sn7 sudo ip link set sn0 up sudo ip addr add 192.168.0.1/24 dev sn0 ip -s link show sn0

ip link set sn0 up会触发驱动的 open 回调,里面要netif_start_queue打开发送队列。如果驱动没调netif_carrier_on,这里的ip -s link show会显示NO-CARRIER且state DOWN——

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进