简介基于《Linux Device Drivers》第二版官方原版snull虚拟网卡驱动源码是学习Linux网络设备驱动开发的经典入门实例。代码完整实现了网络设备核心操作数据包收发、环形缓冲区管理、中断模拟、NAPI轮询模式等并细致展示了发送超时处理、链路层地址填充、MTU修改、ioctl配置等机制适合驱动初学者结合书本章节由浅入深研读。资源共7个文件压缩包仅14KB包含C源文件、头文件、Makefile以及snull_load与snull_unload两个模块加载卸载脚本和对应的备份文件可直接编译进内核并加载运行。已有1576人学习下载堪称网络驱动开发的高频参考资料。通过研读这份源码读者能够掌握alloc_netdev、netif_rx、sk_buff、NAPI调度等关键接口和内核数据结构的真实用法理解网络子系统的收发流程与中断/轮询设计思路为编写真实网卡驱动打下坚实基础。1. 虚拟网卡驱动源码 snull不是玩具是理解网络收发的捷径拿到这份虚拟网卡驱动源代码先别急着放进内核编译。snull 这个名字老内核开发者都不陌生它出自《Linux Device Drivers 第三版》是一份专门用来教学的最小可用网络驱动实现。它不操作任何真实硬件靠内存缓冲模拟网卡的收发包却能让你在纯软件环境里跑通完整的网络栈路径。对刚接触内核网络子系统的人读它比读几十万行的 igb、e1000 那些真实驱动轻松得多。适合做内核驱动入门、需要快速搭虚拟网络环境做原型验证、或者想弄懂 sk_buff 和 net_device 怎么协作的开发者。资源本身是一整套源码没有第三方依赖解压编译就能跑。2. snull 的代码骨架net_device 注册、netdev_ops 挂接和模块参数2.1 先看整体目录这个源码包里到底有什么解压后你会看到典型的 Linux 内核模块工程结构。核心文件就三四个别被其他辅助文件分散注意力。常见的布局是这样的snull/ ├── snull.c # 主驱动源码全部逻辑都在这 ├── snull.h # 头文件定义数据结构 ├── Makefile # 内核模块编译脚本 ├── README # 原作者的使用说明 └── test/ # 可选一些简单的测试脚本snull.c 是全部精华所在约几百行。整个驱动围绕一个核心数据结构展开snull_priv它挂在net_device的私有区域里。每个虚拟网卡实例对应一个这样的结构体里面保存设备状态、缓冲区指针、统计信息和两个设备之间的互联关系。snull 最巧妙的设计是成对工作加载模块时创建两个虚拟网卡它们像一根网线直接对接一端发出的数据包另一端立刻能收到不需要物理网络参与。struct snull_priv { struct net_device *dev; struct net_device_stats stats; int status; // 设备状态 spinlock_t lock; // 自旋锁保护并发访问 struct snull_packet *ppool; // 接收缓冲区池 struct snull_packet *rx_queue; // 待接收的数据包队列 int tx_packetlen; u8 *tx_packetdata; struct net_device *peer; // 对端设备 };这里peer字段是理解整个驱动的钥匙。它把两个网卡绑定在一起发送函数直接把数据写到对端的接收队列模拟一根虚拟网线。spinlock_t用于保护共享缓冲队列在中断上下文和收包处理之间维持同步。理解了这个结构体后面读任何函数都有落脚点。2.2 设备注册这一步alloc_netdev 和模板网卡驱动入口用module_init挂载核心动作是循环创建两个虚拟网卡。每个网卡通过alloc_netdev分配传入一个初始化函数snull_setup。这一步定义了设备的属性类型是以太网MTU 默认 1500硬件地址手动构造。这些都不需要真实硬件纯粹是让网络栈相信这是一个标准以太网卡。static void snull_setup(struct net_device *dev) { ether_setup(dev); dev-netdev_ops snull_netdev_ops; dev-mtu 1500; dev-flags | IFF_NOARP; dev-features | NETIF_F_NO_CSUM; /* 构造一个假的 MAC 地址来源是驱动作者写死的 */ dev-addr_len ETH_ALEN; memcpy(dev-dev_addr, SNULL0, 6); }ether_setup把以太网默认参数填好省去你逐个赋值。IFF_NOARP表示该设备不做 ARP 解析因为它是点对点虚拟链路没有广播和地址解析需求。NETIF_F_NO_CSUM告诉网络栈不需要计算校验和——这是虚拟设备常见的优化省掉 CPU 开销。但这个 flags 选项在后续内核版本里可能有变化编译时留意警告。2.3 netdev_ops 和模块参数所有操作的入口现代内核不再直接给net_device挂函数指针而是统一放在net_device_ops结构体里。snull 的 ops 只实现了五个函数恰恰是网络驱动最核心的操作集合打开、关闭、发包、统计、ioctl。观察哪些函数为空你就知道一个最小驱动必须要实现什么。static const struct net_device_ops snull_netdev_ops { .ndo_open snull_open, .ndo_stop snull_stop, .ndo_start_xmit snull_tx, .ndo_get_stats snull_stats, .ndo_do_ioctl snull_ioctl, };模块参数方面源码里提供bufsize和pool_size两个可调参数。前者控制单个缓冲区大小后者控制预分配的缓冲区数量。insmod 时以insmod snull.ko buf2048 pool30方式传入。默认值一般够用但如果你的测试需要传输大包或者模拟高并发这两个参数值得调整。参数过小在收包高峰时容易出现丢包参数过大则白占内存。后面编译和挂载部分会细说。3. 收发包的核心路径snull_tx 发送与 snull_receive 接收的协同方式3.1 发送路径从网络栈到对端网卡ndo_start_xmit是网络驱动的发送入口网络栈会把一个sk_buff交给驱动驱动负责把数据实际送出去。snull 的发送函数非常直观从 skb 里拷出数据扔到对端设备的接收队列。真实驱动这一步要操作 DMA 描述符和寄存器snull 则用结构体指针直接传递。static int snull_tx(struct sk_buff *skb, struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); int len; char *data, *p; len skb-len; data skb-data; priv-tx_packetlen len; priv-tx_packetdata data; /* 把数据复制给对端的接收队列 */ if (priv-peer) { struct sk_buff *skb2 skb_copy(skb, GFP_ATOMIC); snull_rx(skb2, priv-peer); } dev-stats.tx_bytes len; dev-stats.tx_packets; dev_kfree_skb(skb); return NETDEV_TX_OK; }关键在skb_copy和snull_rx这两个调用。skb_copy复制整个缓冲为什么不能用skb_clone共享数据因为对端是另一个网卡生命周期独立必须持有自己的数据副本。GFP_ATOMIC是中断上下文的标志发送路径可能被中断打断只能用原子分配的标志位。返回NETDEV_TX_OK通知网络栈数据已成功接受网络栈随即释放 skb 的引用。如果驱动对数据还有后续使用就不能在这个点释放。3.2 接收路径缓冲区池和环形队列的手动实现接收路径是实现里最值得读的部分。真实硬件靠中断通知驱动有数据到达snull 没有中断源于是作者用了一个取巧方案收到对端数据时调用netif_rx把 skb 交给网络栈。这个函数模拟了硬中断到达后的行为驱动在软中断上下文里把数据上抛。但缓冲区的管理逻辑是通用的——预分配、入队、出队、补充。这是从真实驱动提炼出来的关键思路。static int snull_rx(struct sk_buff *skb, struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); skb-dev dev; skb-protocol eth_type_trans(skb, dev); skb-ip_summed CHECKSUM_UNNECESSARY; priv-stats.rx_packets; priv-stats.rx_bytes skb-len; netif_rx(skb); return 0; }接收函数里最容易忽略的是eth_type_trans。它根据目的 MAC 地址设置skb-protocol字段告诉上层是 IPv4、ARP 还是其他协议。CHECKSUM_UNNECESSARY和发送端NETIF_F_NO_CSUM对应既然虚拟设备不存在线路传输也就没有校验和的必要。netif_rx把 skb 排入内核的接收队列由软中断NET_RX_SOFTIRQ异步处理。理解这条路径的意义在于真实驱动的 rx 中断服务函数最终做的也是这件事区别只是数据来源。3.3 缓冲区耗尽保护为什么代码里要有snull_restart_tx驱动还有一个snull_restart_tx函数专门处理缓冲池耗尽的情况。因为接收缓冲区是有限个如果对端发来的包太多太快预分配的池子会全部被占满。此时发送方需要暂时停止发送等待缓冲区被释放后再恢复。内核提供流量控制机制核心是netif_stop_queue和netif_wake_queue两个调用。static void snull_restart_tx(struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); /* 检查缓冲区池是否已有空位 */ if (priv-ppool ! NULL) { netif_wake_queue(dev); // 重新启动发送队列 } }snull_rx在消耗掉缓冲区后从池里取一个新包填充并调用snull_restart_tx。这是一种环保型的背压机制缓冲区空了发送方自动刹车又有空位了重新踩油门。很多第一次接触网络驱动的人看到自己的虚拟网卡发包丢包、内存疯长就是因为漏了这个保护。写真实驱动时网卡 FIFO 快满的时候同样逻辑会出现在ndo_start_xmit里这是个非常通用的设计模式。4. 编译与加载适合新手的四步流程和参数验证4.1 编译环境准备内核头文件与 Makefile 写法编译内核模块的第一个前提是内核头文件与当前运行内核匹配。最常见翻车点是直接用系统自带的 gcc 编结果头文件版本对不上一insmod就报version magic不匹配。我的做法是装好linux-headers-$(uname -r)之后再动手。sudo apt install linux-headers-$(uname -r) make -C /lib/modules/$(uname -r)/build M$(pwd) modules第二条命令拆开看-C切换到内核构建目录读取内核的顶层 MakefileM$(pwd)告诉内核构建系统你的模块源码在哪里modules是构建目标。完成后当前目录下出现snull.ko这就是可加载的模块文件。如果你的内核配置了签名验证还需要额外处理签名问题普通开发内核一般不会开。4.2 加载模块和创建设备节点两条命令验证驱动状态加载前先看内核日志确认没有旧版本残留。rmmod snull如果有就先卸载。加载用insmod后面加模块参数。加载完立刻用dmesg查看日志输出驱动正常初始化会打印两个设备节点的名称。sudo insmod snull.ko dmesg | tail -20 ip link show预期输出里会看到snull0和snull1两个虚拟网卡。ip link show会列出它们状态应该是 DOWN但没有报错。加载参数的正确姿势是insmod snull.ko bufsize2048 pool_size32注意参数名必须和源码里module_param定义的完全一致否则 insmod 直接拒绝。如果dmesg里出现Unknown symbol的错误查一下是不是内核版本太新有些导出函数在 2.6 之后被移除了。4.3 配置 IP 和启动测试链路手动模拟一根网线两个设备需要手动配上 IP 才能通信。因为没有硬件中断不需要ifconfig up之外的额外配置。我习惯把两个设备放在两个不同的网段避免内核路由表的干扰但这其实不是必须的。更标准的做法是给两个网卡配上同一个子网的不同地址然后互 ping。但这个驱动不支持 ARP网上很多教程在这里翻车——snull 的设备没开 ARP 功能跨网段的包直接发往网关你 ping 不通是正常的。sudo ip addr add 192.168.3.1/24 dev snull0 sudo ip addr add 192.168.3.2/24 dev snull1 sudo ip link set snull0 up sudo ip link set snull1 up ping -I snull0 192.168.3.2snull0发往192.168.3.2的数据会经由内核的发送路径进入snull_tx再写入snull1的接收队列。整个链路不经过任何物理硬件观察数据是否到达用cat /proc/net/dev查看两个接口的收发计数或者直接抓包验证。如果 ping 不通先检查ip link show里面 UP 状态是否是NO-CARRIER常见问题是内核新版本把虚拟设备的 carrier 状态改了。4.4 模块参数表啥时候调大调大的代价是什么参数默认值作用调大时机代价bufsize1024单个缓冲区的字节数测试 jumbo frame 或超大包每包多占内存pool 数量不变时总内存涨pool_size20预分配缓冲区的数量模拟高入包并发内存占用线性增长初始化变慢dev_nr2创建的网卡对数需要更复杂的拓扑时每对网卡消耗独立内存和队列真实项目中常见误区是一上来就把 pool_size 调到几千结果收包路径还没来得及消费缓冲区内存先爆了。我的习惯是先用默认值跑通再逐步加压观察/proc/net/dev里的 drop 计数。如果 drop 不断增加说明 pool 里的包来不及处理这时候才考虑调大。这个顺序不能反过来——参数只是杠杆瓶颈在网络栈的处理速度盲目调参只会掩盖问题。5. 避坑排查编译报错、网卡不 UP 和丢包三大类问题定位5.1 编译阶段最常栽在 net_device_ops 的类型不匹配现象编译通过但insmod报错或者编译直接报incompatible pointer type。原因snull 源码是给老内核写的net_device_ops里函数指针的返回值类型在 3.x 之后变为netdev_tx_t而旧代码写的是int。解决把snull_tx的返回值类型从int改为netdev_tx_t并显式返回NETDEV_TX_OK。还有一个易忽略的小地方是tx_queue和rx_queue的类型在不同版本里被改名成了sk_buff_head如果你的代码编译报这两个字段找不到把struct sk_buff *改成struct sk_buff_head配合skb_queue_head_init使用。老驱动改新内核编译70% 的报错都属于这类型签名变化别慌张逐个对齐include/linux/netdevice.h即可。5.2 设备无法 upIFF_NOARP惹的祸和 carrier 状态检查现象ip link set snull0 up执行成功但状态始终是NO-CARRIER。原因新内核里netif_carrier_off是设备注册时的默认状态只有驱动调用netif_carrier_on才会变成LOWER_UP。旧版 snull 源码没有这个调用想着虚拟网卡链路永远正常实际内核不认账。解决在snull_open函数末尾加一句netif_carrier_on(dev)。这个函数告诉内核链路物理上是通的路由和邻居子系统才会把它当作可用设备。诊断时用ip link show看状态如果显示NO-CARRIER基本可以断定就是缺这行调用。同理IFF_NOARP会导致 ip 命令无法设置 arp 表项这不是 bug是特性。如果你需要跑标准 ARP 协议栈把dev-flags里这个标志去掉重新编译加载即可。5.3 丢包排查drop 计数在哪个文件看什么值算异常现象ping 通但对大流量测试丢包率很高。原因通常有两个方向一个方向是缓冲区池耗尽另一个方向是发送函数没做流量控制。先看数据再改代码watch -n 1 cat /proc/net/dev会显示rx_dropped和tx_dropped计数。如果rx_dropped持续增长说明snull_rx入队前 skb 分配失败如果tx_dropped增长说明发送队列停掉了没恢复。前者调 pool_size后者查netif_stop_queue和netif_wake_queue是否成对调用。最容易翻车的细节是snull_tx里复制包用的是skb_copy一旦 skb 内存分配失败skb2是 NULL直接调用snull_rx会空指针崩溃。正确做法是分配失败时返回NETDEV_TX_BUSY让网络栈稍后重发。这段逻辑在原版代码里比较隐晦自己加驱动时一定要补上。5.4 内存泄漏主要在缓冲区出入队路径上现象模块加载一段时间后free -m显示内存持续下降卸载模块后不恢复。原因snull_pool预分配的缓冲区在接收路径一次性的露出去了但没有新包来填回去。缓冲区的出入队逻辑如果不对齐池子慢慢变空网络栈手里的 skb 也没有释放泄漏就发生了。解决重点检查snull_release函数是否遍历空闲链表把所有预分配缓冲区全部归还还有dev_kfree_skb是否在每个返回点都执行。写驱动的习惯是每个函数入口先画出所有分支路径标注 skb 的引用计数变化。引用计数加了一次没减就是泄漏减多了UAF 崩溃等着你。snull 代码简单但这两类经典内存问题一个不少值得逐行走读。5.5 模块卸载卡死或触发内核警告现象rmmod snull卡住不动或者卸载后dmesg一堆kernel BUG at ...。原因设备还在运行状态ndo_stop里没有把接收队列清空就释放了内存或者dev_remove之前还有 skb 持有设备引用。解决卸载前先把两个接口都ip link set snull0 down确保驱动进入停止态。源码里snull_stop应当释放所有 rx_queue 里的包并清空标志位。新版内核还要求在net_device释放前把rtnl_lock拿住多网卡设备尤其要小心顺序错了直接死锁。我的排查路径是rmmod前先dmesg -c清空日志卸载后立刻看最后的 kernel panic 现场定位是哪一个函数释放时触发的。6. 验证与进阶tcpdump 抓包、iperf 压测和改造成限速网卡的思路把 snull 跑起来只是第一步真正有价值的是借用它理解内核网络栈然后动手改造。推荐三条验证路径。第一抓包验证。tcpdump -i snull0 -n -v监听 snull0 的流量。注意-i加的是虚拟设备名tcpdump 完全支持这种内存网卡。抓包结果里能看到 ARP 请求被内核直接回复因为设备标记了IFF_NOARP。这本身就是验证内核邻居子系统行为的绝佳实验。第二压测。iperf3 -s在 192.168.3.2 上起服务端iperf3 -c 192.168.3.2 -i 1从 snull0 侧跑客户端。由于数据不经过物理线路吞吐瓶颈从网卡变成了内核协议栈的处理速度。观察 TCP 窗口变化与 CPU 占用你会直观地看到软中断NET_RX_SOFTIRQ的 CPU 占比。这一步对理解内核收包路径的 CPU 开销很有帮助。第三一个推荐的改造方向给 snull 加限速逻辑。在snull_tx里用ktime_get记录发包时刻如果两次发包间隔小于设定值记录tx_dropped并提供snull_ioctl命令查询当前速率。这一步做完你会对流量整形和令牌桶有更实际的理解内核的netem模块也是基于类似思路实现的。/* 伪代码示例用 ktime 实现简单的发包限速 */ static ktime_t last_tx; static u64 min_interval_ns 1000; // 1us 一个包 if (ktime_before(ktime_get(), ktime_add_ns(last_tx, min_interval_ns))) { dev-stats.tx_dropped; return NETDEV_TX_BUSY; } last_tx ktime_get();这段逻辑接在snull_tx开头实际意义就是给虚拟网卡加了一个软件限速器。从那以后我每次阅读网络驱动源码都会先确认数据从哪进、从哪出、缓冲区怎么续命这三个问题理清了再把驱动加载进内核。snull 虽然老但代码里的设计模式至今沿用值得花一个下午通读并亲手改一版。希望帮到你。本文还有配套的精品资源点击获取