首页 / 资讯中心 / 文章详情

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

Linux虚拟网卡snull驱动源码拆解:net_device与skb收发闭环 ★ FEATURED ARTICLE
简介面向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_netdev8 个设备是怎么来的源码里有一个固定长度的设备指针数组长度定义成一个宏常见值是 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) cleanobj-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 sn0ip link set sn0 up会触发驱动的 open 回调里面要netif_start_queue打开发送队列。如果驱动没调netif_carrier_on这里的ip -s link show会显示NO-CARRIER且state DOWN——本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站