Linux虚拟网卡驱动snull收发链路实战解析

发布时间:2026/10/23 18:19:49
Linux虚拟网卡驱动snull收发链路实战解析 简介面向Linux内核驱动初学者与网络子系统学习者的snull虚拟网卡驱动原版代码改编自《Linux Device Drivers》配套示例通过两个虚拟接口之间的回环收发演示网络设备驱动的注册、打开关闭、报文发送接收、中断处理与NAPI轮询等核心流程并保留锁、skb、统计信息及模块参数等实验要素。压缩包共7个文件约14KB包含C源文件、头文件、Makefile以及加载卸载脚本和备份文件结构紧凑便于对照书中章节在旧版内核环境中阅读和动手编译。已有1579人学习下载。读者可据此理解虚拟网卡如何模拟硬件收发、如何挂接内核网络栈并借助lockup、timeout、use_napi、pool_size等参数开展中断、超时与轮询模式实验积累驱动调试和排错思路。1. 手上只有一台机器怎么验证收发逻辑通不通手上没有第二台机器也不方便动真实网卡的时候怎么验证一段收发逻辑到底通不通我的做法是先跑一遍 snull它只做一件事在内存里造出两块虚拟网卡再把它们当一根网线插在一起从 snull0 发出的帧会原样从 snull1 收进来。整个驱动不到千行却完整走了一遍内核 networks 子系统的注册、发送、接收、统计四条主线。它适合三类人刚接手驱动维护、想弄明白数据包到底从哪进内核的开发者要给上层协议写测试桩的人以及只想在容器或虚拟机里造一个可控网络环境、不想碰真实硬件的运维。2. 项目定位与技术栈snull 用两块虚拟网卡拼出一条“网线”snull 不是那种能替代真实网卡的生产级驱动它的价值在于“最小可运行”。真实网卡要处理 DMA 环、寄存器、PHY 协商、硬件中断代码里一大半篇幅都在跟硬件时序较劲snull 把这些统统砍掉只保留驱动模型必须实现的那几个回调。所以读它的收益不是学会写某个型号的网卡驱动而是搞清楚内核网络栈给驱动留了哪几个口子、每个口子的契约是什么。2.1 回声模型snull0 发出的帧从 snull1 收进来理解这个项目只要记住一条数据流上层协议栈把 skb 交给ndo_start_xmit驱动把数据复制一份塞进对端设备的接收缓冲再从接收路径重新送回协议栈。发送方看到的是“我发出去了”接收方看到的是“我收到了一个以太网帧”中间没有任何物理介质。这里有个容易被忽略的设计取舍两个设备共用同一份内核内存所以“发出的包”和“收到的包”其实是同一条主机上的一次内存拷贝不会真的经过网卡队列也不会触发硬件中断。驱动里那段“中断模拟”是刻意写给人看的它把“硬件什么时候通知 CPU 有包到了”这件事显式化让你能清楚地看到中断上下文和协议栈之间的边界在哪里。也正因为两端在同一台机器同一份网络栈里内地址配置会变得很别扭——你给两块卡配同一网段的 IP内核会认为目的地址是本机包压根不会走到驱动里。这个问题后面第 5 章会单独拆它是新手第一个翻车点。2.2 技术栈与内核接口清单项目是纯 C 写的内核模块不依赖任何用户态库编译产物就是一个.ko。用到的接口全部来自内核网络子系统没有第三方依赖这也是它适合当教材的原因——你把下面这张表里的每个接口查一遍文档基本就掌握了网卡驱动的骨架。内核接口在项目里承担的角色alloc_netdev/register_netdev分配 net_device 结构并注册进网络栈ether_setup填好以太网类型的默认参数MTU、头长、typenetdev_priv取出跟在 net_device 后面的私有数据区ndo_start_xmit协议栈唯一的发送入口dev_alloc_skb/skb_reserve/skb_put_data造接收缓冲、做头部对齐、填数据eth_type_trans按以太网头判定上层协议和包类型netif_rx把帧挂回协议栈的接收队列timer_list模拟“硬件中断延迟到达”module_param运行期调整驱动行为免去反复编译值得说一下netif_rx这个选择。它是老接口把 skb 挂到 per-CPU 的 backlog 队列随后在软中断里走netif_receive_skb新代码更推荐直接调用netif_receive_skb或走 NAPI 的napi_gro_receive。snull 用netif_rx是因为它要演示“中断上下文不能直接跑协议栈”这件事用 NAPI 反而把这层教学意图盖住了。你要把它改成生产可用的虚拟设备第一件事就是把这条路径换成 NAPI。2.3 源码文件与职责分布拿到源码包先别急着make花两分钟看清文件分工后面改代码时能省掉一半的搜索时间。典型的目录长这样文件内容snull.c全部驱动逻辑注册、发送、接收、统计、ioctlsnull.h私有结构体snull_priv与常量定义Makefile标准的外部模块编译规则走M$(PWD)snull_load/snull_unload装载脚本封装 insmod 配地址 加路由的一串命令README编译与验证步骤通常还带着原始作者的测试记录snull.c是唯一需要精读的文件其余都是脚手架。我一般会先通读一遍snull.c里所有static函数的签名不进去看实现——签名会告诉你这个驱动实现了哪些回调、哪些是内部辅助函数比从头读快得多。3. 核心链路拆解从 alloc_netdev 到 netif_rx 的完整一跳这一章按数据包的生命周期走一遍设备怎么被造出来、发送时驱动拿到什么、帧怎么被“送过网线”、接收侧怎么把它交回去。代码是基于原始版本做的现代化整理原始代码用的是init_module那套老写法新内核上编译不过逻辑没动。3.1 设备注册snull_init_module 与 alloc_netdev模块加载函数只干三件事分配两块设备、写死 MAC 地址、注册进网络栈。注意alloc_netdev的第二个参数是格式串snull%d%d由内核自动填索引所以两块卡自然叫 snull0 和 snull1不用你手动拼名字。/* snull.c —— 模块加载一次性注册两块互连的虚拟网卡 */ static struct net_device *snull_devs[2]; static int __init snull_init_module(void) { int i, err; /* 1. 分配 net_device并在它后面预留 sizeof(struct snull_priv) 的私有区 */ snull_devs[0] alloc_netdev(sizeof(struct snull_priv), snull%d, NET_NAME_UNKNOWN, snull_init); snull_devs[1] alloc_netdev(sizeof(struct snull_priv), snull%d, NET_NAME_UNKNOWN, snull_init); if (!snull_devs[0] || !snull_devs[1]) { err -ENOMEM; goto out_free; } /* 2. “烧”两个不会冲突的 MAC真实网卡是从 EEPROM 读这里是编的 */ snull_devs[0]-dev_addr[0] 0x00; snull_devs[0]-dev_addr[1] 0x11; snull_devs[0]-dev_addr[5] 0x01; memcpy(snull_devs[1]-dev_addr, snull_devs[0]-dev_addr, ETH_ALEN); snull_devs[1]-dev_addr[5] 0x02; /* 3. 注册成功后 /sys/class/net/ 下立刻出现两个目录 */ for (i 0; i 2; i) { err register_netdev(snull_devs[i]); if (err) { pr_err(snull: register_netdev(%d) failed: %d\n, i, err); goto out_unreg; } } pr_info(snull: loaded, bring snull0/snull1 up to start\n); return 0; out_unreg: while (--i 0) unregister_netdev(snull_devs[i]); out_free: free_netdev(snull_devs[0]); free_netdev(snull_devs[1]); return err; } module_init(snull_init_module);alloc_netdev的第三个参数NET_NAME_UNKNOWN是给dev-name_assign_type用的老内核里根本没这个参数从 3.13 之后才有。第四个参数snull_init是 setup 回调在内核内部被调用用来填充默认字段——这个回调不是在你这行代码里直接执行的它是被alloc_netdev转手调用的很多人第一次读会以为是笔误。出错路径的写法值得学一下goto out_unreg之前先把i自减这样清理循环只会回滚已经注册成功的设备。这种“逆序回滚”是内核代码的标准姿势比每个分支写一遍清理更不容易漏。3.2 私有数据结构netdev_priv 里的那块自留地驱动的状态不能挂在全局变量上否则多设备就完蛋。内核的做法是让 net_device 后面跟一段变长内存用netdev_priv(dev)取指针。这段内存大小就是你传给alloc_netdev的第一个参数。/* snull.h —— 每个设备一份的私有区netdev_priv() 返回的就是它 */ struct snull_priv { struct net_device_stats stats; /* 老式统计结构ifconfig 直接读 */ struct net_device *dev; /* 反查用定时器回调里需要 */ struct sk_buff *rx_skb; /* “硬件接收缓冲”只有一格 */ int rx_int_enabled; /* 是否开启接收中断 */ unsigned long rx_int_delay; /* 中断延迟单位 jiffies */ struct timer_list rx_timer; /* 中断模拟用的定时器 */ spinlock_t lock; /* 保护 rx_skb 这格缓冲 */ }; static const struct net_device_ops snull_netdev_ops { .ndo_open snull_open, .ndo_stop snull_release, .ndo_start_xmit snull_tx, .ndo_get_stats snull_get_stats, .ndo_do_ioctl snull_ioctl, .ndo_set_mac_address eth_mac_addr, .ndo_validate_addr eth_validate_addr, };struct net_device_stats是新内核里已经被pcpu_sw_netstats/rtnl_link_stats64取代的东西ndo_get_stats也逐步让位给ndo_get_stats64。原始 snull 用的是前者编译在新内核上会警告甚至报错。移植时最省事的做法是保留net_device_stats但把回调换成ndo_get_stats64在回调里把字段搬到struct rtnl_link_stats64。setup 回调里除了初始化私有区还要把netdev_ops挂上去、设置IFF_NOARP、打开校验和卸载。IFF_NOARP是这个项目的关键开关关掉 ARP 之后内核不会为这个设备发 ARP 请求而是直接把包扔给ndo_start_xmit所谓“点对点链路”就是靠它实现的。static void snull_init(struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); ether_setup(dev); /* 补齐 MTU、hard_header_len、type 等默认值 */ memset(priv, 0, sizeof(*priv)); spin_lock_init(priv-lock); priv-dev dev; priv-rx_int_enabled 1; priv-rx_int_delay timeout; /* 模块参数默认 5 个 jiffies */ dev-netdev_ops snull_netdev_ops; dev-flags | IFF_NOARP; /* 对端固定不需要地址解析 */ dev-features | NETIF_F_HW_CSUM; /* 告诉上层校验和交给“硬件”别重复算 */ init_timer(priv-rx_timer); /* 新版内核用 timer_setup() */ priv-rx_timer.function snull_rx_timer; priv-rx_timer.data (unsigned long)dev; }ether_setup必须在改dev-type之类的字段之前调用因为它会一次性覆盖掉一批以太网默认值。这个顺序如果搞反你会看到 MTU 变成 1500 但hard_header_len还是 0抓包时帧头解析全乱——这个坑我在别的地方踩过排查了大半天才发现是两行代码写反了。3.3 发送路径ndo_start_xmit 与 snull_hw_txndo_start_xmit是协议栈唯一的发送入口签名是netdev_tx_t (*)(struct sk_buff *, struct net_device *)。老内核返回int2.6.31 之后改成netdev_tx_t枚举这是移植时必改的一处。static netdev_tx_t snull_tx(struct sk_buff *skb, struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); unsigned char shortpkt[ETH_ZLEN]; unsigned char *data skb-data; int len skb-len; /* 以太网最小帧 60 字节不足要填充否则对端网卡会当成残帧丢掉 */ if (len ETH_ZLEN) { memset(shortpkt, 0, ETH_ZLEN); memcpy(shortpkt, skb-data, skb-len); len ETH_ZLEN; data shortpkt; } priv-stats.tx_packets; priv-stats.tx_bytes skb-len; spin_lock(priv-lock); snull_hw_tx(data, len, dev); /* 交给“硬件”发出去 */ spin_unlock(priv-lock); dev_kfree_skb(skb); /* 发完就把上层这份还回去别留着 */ return NETDEV_TX_OK; }几个参数要注意ETH_ZLEN是 60不含 CRC所以抓包工具看到的以太网帧长度是 60 而不是 64NETDEV_TX_OK表示帧已被驱动接管返回NETDEV_TX_BUSY会让上层重试用错了表现是发送队列卡死、ifconfig里tx dropped一路涨。dev_kfree_skb放在最后是必须的。skb 的所有权在调用ndo_start_xmit之后完全归驱动你不释放就是内存泄漏反过来释放之后再去读skb-data就是访问已释放内存这种错误在测试时往往看不出来跑久了才 oops。真正的“硬件发送”在这个函数里/* 把帧从 dev 送到对端设备模拟一根网线连着两块卡的两端 */ static void snull_hw_tx(unsigned char *buf, int len, struct net_device *dev) { struct snull_priv *priv netdev_priv(dev); struct net_device *dest (dev snull_devs[0]) ? snull_devs[1] : snull_devs[0]; struct snull_priv *dest_priv netdev_priv(dest); struct sk_buff *skb; skb dev_alloc_skb(len 2); if (!skb) { priv-stats.tx_dropped; return; } /* 对端那格“硬件缓冲”还占着就当线路拥塞丢掉别覆盖 */ if (dest_priv-rx_skb) { priv-stats.tx_dropped; dev_kfree_skb(skb); return; } skb_reserve(skb, 2); /* 让 IP 头 4 字节对齐省掉一次 memmove */ skb_put_data(skb, buf, len); /* 老内核写法memcpy(skb_put(skb, len), buf, len) */ dest_priv-rx_skb skb; /* 中断关闭时立刻“响”打开时按 timeout 延迟模拟硬件时延 */ mod_timer(dest_priv-rx_timer, jiffies (dest_priv-rx_int_enabled ? dest_priv-rx_int_delay : 0)); }skb_reserve(skb, 2)那两字节不是凑数。以太网头是 14 字节加上 2 之后 IP 头正好落在 16 字节边界上CPU 取iphdr里的 32 位字段不用跨缓存行这个技巧在真实驱动里到处都是属于性能细节里性价比最高的一类。dest_priv-rx_skb只有一格这是刻意设计的“硬件只有一块接收缓冲”。真实网卡是环形队列满了就丢包并计入rx_over_errorssnull 用一格 丢弃来简化效果一样你能在统计里看到丢包。3.4 接收路径中断模拟、定时器与 netif_rx定时器到点后执行的就是“中断服务程序”。它把硬件缓冲里的帧取出来补上dev和protocol然后交给netif_rx挂进协议栈队列。这一步不能直接调netif_receive_skb老接口语义下因为你还在定时器上下文里协议栈可能睡眠。/* “中断服务程序”定时器到点后被调用把帧转成 skb 交给协议栈 */ static void snull_rx_timer(unsigned long data) { struct net_device *dev (struct net_device *)data; struct snull_priv *priv netdev_priv(dev); struct sk_buff *skb priv-rx_skb; if (!skb) /* 中断是共享的别的设备可能已经处理过 */ return; priv-rx_skb NULL; skb-dev dev; /* 必须设eth_type_trans 要用它判 pkt_type */ 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); /* 挂到 per-CPU 后备队列软中断里再往上走 */ }eth_type_trans干的活比名字看起来多它从以太网头的h_proto里读出上层协议号填到skb-protocol根据目的 MAC 判断是单播、广播还是发给别人的设置skb-pkt_type还会顺手把skb_pull掉 14 字节以太网头让skb-data指向 IP 头。所以调完它之后你不能再读eth_hdr(skb)那个位置的数据已经被“逻辑上移走”了。CHECKSUM_UNNECESSARY是配合前面NETIF_F_HW_CSUM用的。虚拟设备根本没算校验和但上层会因为这两个标志跳过校验所以抓包看到的是完整可用的包。如果你只设了其中一个就会出现“包收到了但 TCP 校验和错误”的玄学现象——这类 bug 查起来是真的费劲因为 ping 通、telnet 不通。3.5 统计与 ioctl让 ifconfig 有数字可看统计回调把私有区里攒的计数搬给上层逻辑很简单但没有它你就失去了最有效的调试手段。发包不通的时候ip -s link show snull0里 tx/rx 四个数字谁在涨直接决定了问题出在发送前还是接收后。/* 打开设备启动发送队列、拿回统计基线的时机 */ static int snull_open(struct net_device *dev) { netif_start_queue(dev); /* 允许上层把包交给 ndo_start_xmit */ return 0; } static int snull_release(struct net_device *dev) { netif_stop_queue(dev); /* 停止接收新的发送请求 */ del_timer_sync(((struct snull_priv *)netdev_priv(dev))-rx_timer); return 0; } /* 用户态通过 SIOCDEVPRIVATE 开关“中断”调试时不用重编模块 */ static int snull_ioctl(struct net_device *dev, struct ifreq *rq, int cmd) { struct snull_priv *priv netdev_priv(dev); if (!netif_running(dev)) return -EINVAL; if (!capable(CAP_NET_ADMIN)) return -EPERM; switch (cmd) { case SIOCDEVPRIVATE: { int val; if (get_user(val, (int __user *)rq-ifr_data)) return -EFAULT; priv-rx_int_enabled !!val; return 0; } default: return -EOPNOTSUPP; } }netif_start_queue/netif_stop_queue这对函数控制的是“协议栈能不能往这个设备发”。如果ndo_open里忘了调netif_start_queue现象特别迷惑设备是 UP 的路由也有ping就是不发包tx计数永远是 0。我在别处被这个坑过一回后来养成习惯写ndo_open时第一行就把netif_start_queue敲上。4. 编译与运行把 snull.ko 装进内核并抓到第一个包4.1 Makefile 与内核头文件准备外部模块编译只要一个三行的 Makefile重点是M$(PWD)这个参数——它告诉内核的构建系统“源码在当前目录别去内核树里找”。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) cleanKDIR指向的是内核源码的构建目录不是/usr/src下的头文件目录。很多系统的/lib/modules/$(uname -r)/build是个软链接指向完整内核树如果这个软链接断了make会报 “No such file or directory”而你ls /usr/src又能看到一堆头文件目录很容易误判成缺包。准备步骤通常是装build-essential和对应版本的内核头文件包然后直接make。编译过程中出现的最多的是 API 对不上的错误不是语法错——这部分留到第 5 章细讲。4.2 加载与配置让包真的走驱动insmod之后设备就出现在系统里了但默认是 DOWN 状态也不会自动配地址。手动走一遍sudo insmod ./snull.ko ip link show | grep snull # 应该能看到 snull0 / snull1 两个 DOWN 状态的设备 sudo ip link set snull0 up sudo ip link set snull1 up # 关键两块卡配不同网段再手动指定路由否则内核认为目的地在本地包不会进驱动 sudo ip addr add 192.168.0.1/24 dev snull0 sudo ip addr add 192.168.1.1/24 dev snull1 sudo ip route add 192.168.1.0/24 dev snull0为什么非要配不同网段因为两台“网卡”在同一条主机上如果你给它们配同一网段的地址内核查路由表时看到目的地址是本机地址会直接把包从 loopback 送回去ndo_start_xmit本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询