ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

snull虚拟网卡驱动解析:从内核骨架到数据收发实践

snull虚拟网卡驱动解析:从内核骨架到数据收发实践 简介《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-typeARPHRD_ETHER、默认 MTU1500、hard_header_len14 字节如果不调它后续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指向snull1snull1的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都设好 IPifconfig状态正常但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_skbhard_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-CARRIERping 不通。原因驱动的snull_stop在 down 时把队列清空但没有重置私有状态里的status标志位。重新 up 时snull_open检测到状态异常直接放弃设备初始化。解决在snull_stop里把私有数据的status置为 0并在snull_open里重新初始化skb_queue——清掉残留 skb重置计数器。这是驱动常见 bug停设备做了清理但清理不彻底恢复时旧状态残留导致启不来。调试时多看一眼dmesgsnull_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_loss10 snull_delay5。有了参数化之后你可以在用户态通过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 源码的拆解能帮你在网络驱动的入口处少走弯路把虚拟设备当作真实硬件的替身动手测出自己的判断力。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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