简介:一份源自《Linux设备驱动程序》原书配盘的虚拟网卡驱动源代码(snull),专为Linux内核网络驱动开发者、内核模块程序员及嵌入式网络工程师准备,是理解真实网络驱动运行机制的经典范例。该驱动不依赖任何物理硬件,完全用软件构造两个虚拟网络接口,并将发送方发出的数据包回环转发至接收方,同时在IP头部修改源地址和目的地址的第三字节以模拟跨网传输,使学习者可以在普通PC上安全跟踪整个收发路径。压缩包内共7个文件,包括驱动主程序、配套头文件、两个备份文件、加载模块所需的加载脚本与卸载脚本,以及用于编译的Makefile构建文件,整个资源仅14KB,代码紧凑、注释详尽。实现方面覆盖了网络设备驱动的所有常见环节:设备注册与注销、打开与停止、基于包池的发送缓冲获取、内核套接字缓冲区构造与校验和修正、中断状态机与NAPI轮询、发送超时和锁死模拟、扩展接口、统计字段更新,并且通过模块参数可切换到轮询模式或设置超时阈值,非常适合逐行研读、模块实验以及二次开发。该资源已有1576人学习下载,建议配合原书章节进行对照阅读,可以快速建立起从硬件抽象、内存管理到内核协议栈交互的完整图景。
1. 虚拟网卡驱动源代码在讲什么:报错“虚拟网卡不存在或被禁用”时真正缺的是什么
很多人第一次接触虚拟网卡驱动,不是因为它好玩,而是因为某个程序装完弹出一句“虚拟网卡不存在或被禁用”,或者虚拟机里怎么都找不到第二块网卡。查来查去,注册表和服务都是好的,最后才发现:系统里根本没有一个能用的虚拟网卡驱动模块,或者说,你拿到的所谓“原版”驱动源码,压根没有针对当前内核编译通过。这篇文章要解决的就是这件事:搞清楚一份真正的虚拟网卡驱动源代码长什么样,怎么从上游内核源码里把它编出来,装上去之后如何验证它真的在收发报文。
这里先给一个反直觉的结论:网上流传的大多数“虚拟网卡驱动源代码(原版)”,其实都不是真正的原版。真正被全世界无数项目直接使用的原版,就藏在 Linux 内核树的drivers/net/tun.c里,一份文件就是一个完整的、能编译、能加载、能被ip tuntap直接调用的虚拟网卡驱动。如果你在做容器网络隔离、虚拟机网络、流量采集,或者想写一个自定义的虚拟网络设备,这份代码是绕不开的起点。
2. 读懂原版驱动的报文路径:从用户态 write() 到内核协议栈
2.1 一个字符设备加一个 net_device:虚拟网卡驱动的全部模型
先破除一个玄学:虚拟网卡驱动并不像显卡或 WiFi 驱动那样需要处理 PCIe 中断、DMA 描述符和固件。它只做好两件事——向内核注册一个struct net_device,同时注册一个字符设备/dev/net/tun。用户态程序往这个字符设备里write()一段数据,等价于一根虚拟网线把报文从“外面”送了进来;用户态程序read()这个设备,等价于从网线上把协议栈要发出去的报文取走。整个驱动模型就是围绕这两个方向转的。
/* 与 drivers/net/tun.c 结构一致,这里只留骨架便于理解 */ static netdev_tx_t tun_net_xmit(struct sk_buff *skb, struct net_device *dev) { /* 协议栈要求发报文时进入这里,把 skb 挂到队列, * 然后唤醒等待 read() 的用户态程序 */ } static ssize_t tun_chr_write_iter(struct kiocb *iocb, struct iov_iter *from) { /* 用户态 write() 最终到达这里, * 从 iov_iter 取数据,构造 skb,调用 tun_get_user() */ } static ssize_t tun_get_user(struct tun_struct *tun, void *msg, ...) { /* 真正的“收包”入口:从用户态缓冲区拷贝到 skb, * 填充协议头,最后 netif_rx() / napi_gro_receive() 入协议栈 */ }注意方向别搞反:用户态write()对应的是“网卡收到包”,驱动会把这个包送进内核协议栈;用户态read()对应的是“网卡发出包”,协议栈调用tun_net_xmit()后由驱动把包交给用户态。我最早调试时就把这个方向搞反了,程序一直read不到数据,还以为是驱动坏了,其实是逻辑完全反了。看源代码时先抓住这两个方向,后面所有流程都能顺下来。
就读源码而言,tun_get_user()是收包路径的核心,tun_net_xmit()是发包路径的核心。你在网上找到的所谓原版,如果连这两个函数都对不上,大概率是被二次开发过的版本,不建议作为基线去排查问题。
2.2 TUN 与 TAP 差在哪:多种模式下如何选型
打开tun.c的头文件定义,你会看到两个核心标志:IFF_TUN和IFF_TAP。它们只差一个比特位,但决定了这个虚拟网卡工作在 OSI 第三层还是第二层。
| 模式 | 工作层次 | 报文内容 | 典型使用场景 | 网卡形态 |
|---|---|---|---|---|
| TUN | 网络层(L3) | IP 报文,不含以太网头 | 跨主机路由、自定义协议封装、流量采集 | 点对点接口,没有 MAC 地址 |
| TAP | 数据链路层(L2) | 完整以太网帧,含 MAC 头 | 虚拟机接入网桥、容器 macvlan、二层隔离 | 标准以太网接口,有 MAC 地址 |
用ip tuntap add dev tun0 mode tun创建的是 TUN,mode tap创建的是 TAP。如果你要模拟一块真实网卡给虚拟机用,选 TAP;如果你只是想把 IP 报文接进用户态程序处理,选 TUN 更省事,少一层 MAC 解析。
另一个经常被忽略的标志是IFF_NO_PI。创建接口时如果不加它,驱动会在每个报文前面塞一个 4 字节的struct tun_pi头,包含 flags 和协议类型。用户态程序读数据时得多解析 4 个字节,写数据时也得先填这 4 个字节。绝大多数场景用IFF_NO_PI把控制头去掉,直接收发裸报文。采坑经验:有些教程里的“原版”代码默认带 PI 头,你移植到自己的程序里时如果没去掉,抓包会看到每个包前面多出 4 个奇怪的字节。
2.3 收包链路上容易被忽视的实现细节:skb、队列与流控
看tun_get_user()的代码会发现,驱动并不直接调用netif_receive_skb()把包交给协议栈,而是走了两条不同的路:如果关闭了 GRO,调用netif_rx()把 skb 放入 CPU 的输入队列;如果开启了 GRO,则走napi_gro_receive()做 GRO 合并后再上送。你在ethtool -K tun0 gro on里改的开关,实际改变的就是这条路径。
队列方面,老版本的tun.c只有单个队列,所有报文挤在一个 skb 队列里。后来加入IFF_MULTI_QUEUE,每个队列对应一个文件描述符,用户态可以开多个线程分别read/write,吞吐量才能上去。创建时这样写:ip tuntap add dev tun0 mode tun multi_queue。如果你只用一个 fd 操作一个多队列接口,驱动会默认把报文都放到队列 0,性能提升为零。
流控也要单独说:虚拟网卡没有物理环形缓冲区,唯一排队的地方是net_device的tx_queue_len。ip link set tun0 txqueuelen 10000这个参数直接决定tun_net_xmit()在用户态处理不过来时是排队还是丢包。默认 1000 的队列长度在突发流量下很容易触发tx_dropped,这点后面排错会专门讲。
3. 用上游内核源码编译虚拟网卡驱动:最小构建与三个必调参数
3.1 什么才算“原版”:从主线内核树取一份干净源码
我理解的“原版”,不是某个网站打包的“虚拟网卡驱动源码合集”,而是 Linux 主线内核仓库里那一份未被二次修改的drivers/net/tun.c。发行版内核会在上面打补丁,第三方项目会再改,但主线版本永远是功能基线。排查问题、对比行为差异时,我一般都以主线为准。
# LTS 或当前运行版本均可,以下用 6.x 示意 cd /usr/src tar xJf linux-6.x.tar.xz cd linux-6.x # 把当前运行内核的配置拷贝过来,作为构建基线 cp /boot/config-$(uname -r) .config # 确认 TUN 被设置为模块 scripts/config --module TUN --enable TUN make olddefconfig这里scripts/config是内核自带的配置修改工具,不用手工去翻.config文件找CONFIG_TUN。--module TUN告诉 Kbuild 把 tun 编成.ko,--enable TUN是兜底,确保它至少被启用。make olddefconfig会把其余所有配置项按新内核的默认值补齐,避免因为某个依赖项缺失导致编译失败。
拿到源码后,我习惯先看一眼drivers/net/Kconfig里 TUN 的依赖项。TUN依赖NET_CORE,而NET_CORE基本所有内核配置都有,所以一般不会缺依赖。真正容易踩的坑是版本跨度:6.0 的源码在 6.1 的运行内核上编译,大概率因为函数签名和结构体定义不同而编不过。想少走弯路,最常见做法是选一个和你当前uname -r小版本一致的 LTS 源码。
3.2 最小编译流程:确认 TUN 以模块方式编出来
编译单个内核模块不需要把整个内核编完,这是血泪经验换来的。我第一次编虚拟网卡驱动时直接make -j8,半小时后发现其实只要三步前置准备加一个目标文件。
# 生成模块编译所需的基础文件 make modules_prepare # 单独编译 tun 模块 make drivers/net/tun.ko # 查看模块信息,确认 vermagic 与当前内核一致 modinfo drivers/net/tun.ko # 加载模块并确认 insmod drivers/net/tun.ko lsmod | grep tun dmesg | tail -5make modules_prepare会生成Module.symvers、头文件等编译模块必需的中间产物,这一步如果报缺文件,先执行make prepare再回来。make drivers/net/tun.ko只编一个目标文件,比make M=drivers/net更精准,编出来的tun.ko就在源码树的drivers/net/下。
insmod之前一定要看modinfo输出的vermagic,它的格式类似6.1.0-17-generic SMP mod_unload。如果和你运行内核的uname -r差一个字符,加载时直接报invalid module format,没有任何商量余地。发行版内核一般带自研补丁,vermagic 里的后缀可能和主线源码编出来的不一样,这时候要么用发行版提供的linux-headers源码编译,要么你在.config里关掉CONFIG_MODULE_SIG和CONFIG_MODVERSIONS再重新make modules_prepare。
3.3 创建第一个 tun0:命令行与最小 C 程序两种方式
模块加载成功后,/dev/net/tun这个设备节点由驱动自动创建。接下来先给这个虚拟网卡分配 IP 并启用,看看链路能不能起来。
# 创建 TUN 设备 ip tuntap add dev tun0 mode tun # 分配地址并启用 ip addr add 192.168.77.1/24 dev tun0 ip link set tun0 up # 查看状态 ip link show tun0命令行方式适合快速验证驱动模块是否工作。注意ip tuntap add之后接口默认是 down 的,必须ip link set up。这一步如果报Operation not permitted,不是驱动问题,是当前用户缺少CAP_NET_ADMIN权限,root 或sudo可解。
但命令行创建接口和你自己的应用程序没有关系,程序里要用还得通过 ioctl。下面是最小的 C 程序骨架,可以直接编译运行。
#include <fcntl.h> #include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/ioctl.h> #include <linux/if.h> #include <linux/if_tun.h> int main(void) { /* 打开字符设备,O_RDWR 同时支持读写 */ int fd = open("/dev/net/tun", O_RDWR); if (fd < 0) { perror("open"); return 1; } struct ifreq ifr; memset(&ifr, 0, sizeof(ifr)); /* IFF_TUN 表示三层点对点接口,IFF_NO_PI 去掉 4 字节控制头 */ ifr.ifr_flags = IFF_TUN | IFF_NO_PI; strncpy(ifr.ifr_name, "tun0", IFNAMSIZ - 1); /* 把接口绑定到这个 fd 上 */ if (ioctl(fd, TUNSETIFF, &ifr) < 0) { perror("TUNSETIFF"); close(fd); return 1; } /* 循环读取“网卡收到”的报文 */ char buf[2048]; ssize_t n; while ((n = read(fd, buf, sizeof(buf))) > 0) { printf("packet len=%zd: %02x %02x %02x %02x\n", n, buf[0], buf[1], buf[2], buf[3]); } return 0; }编译用gcc -o tuntest tuntest.c即可。注意:这个程序里TUNSETIFF要求进程有CAP_NET_ADMIN权限,普通用户跑会报TUNSETIFF: Operation not permitted,sudo运行即可。程序read()不到数据不代表驱动坏了,它只是把从“网线”进来的包读出来,你得先在另一个终端 ping 对端地址,触发协议栈产生报文,才能看到输出。
3.4 三个必调参数:MTU、多队列与 GRO 协同
驱动能跑起来只是第一步,真正决定性能上限的是三个参数,我每次搭建虚拟网卡环境都会按这个顺序调。
MTU是最容易出问题的参数。默认 1500 在物理以太网上没问题,但如果你把虚拟网卡接到桥接链路上,或者承载 VLAN 标签、额外协议头,报文大了就会触发分片。跨主机场景下,两端 MTU 必须一致,否则表现为小包通、大包丢。调试时可以用ping -M do -s 1472 对端探测路径 MTU,逐字节往下压,直到找到临界值。
txqueuelen前面提过,是虚拟网卡唯一的排队空间。看驱动的 xmit 路径就知道了:tun_net_xmit()把 skb 放进队列后返回NETDEV_TX_OK,如果用户态程序没来得及read(),队列满了就直接丢。突发流量下,我会把txqueuelen从默认 1000 提到 10000,代价是内存占用上升,但丢包率会明显下降。
# 调整队列长度 ip link set tun0 txqueuelen 10000 # 创建多队列接口 ip tuntap add dev tun0 mode tun multi_queue # 查看 GRO 状态 ethtool -K tun0 gro on ethtool -K tun0 gso onGRO/GSO是另一个容易忽略的坑。开启后驱动会把多个小包合并成大包再送协议栈,单次read()拿到的是合并后的超大包,吞吐测试数字会很好看,但如果你是做逐包解析的应用,反而要在应用层做分片重组。反过来,如果关闭 GRO,纯小包场景下协议栈开销会变大。常见做法是:虚拟化桥接场景保持默认开启,用户态做逐包处理的场景显式关闭。
注意:多队列只有在搭配多线程用户态程序时才有意义。单线程程序无论开多少队列,永远只用队列 0。
4. 虚拟网卡驱动的常见故障排查:设备消失、丢包与装不上
4.1 “/dev/net/tun 不存在或被禁用”:先查模块,再查权限
现象:程序open("/dev/net/tun")返回No such file or directory,或者系统里根本找不到这个设备节点。
原因:最常见的是tun模块没有加载。容器环境下更常见的是/dev/net/tun没有被映射进容器,或者容器缺少NET_ADMINcapability,导致即使设备节点存在也无法 ioctl。
解决:先modprobe tun,再ls -l /dev/net/tun。如果/dev/net目录都没了,驱动模块加载后会自动创建,不用手工mknod。容器场景则要在启动时带上设备和白名单:docker run --device /dev/net/tun --cap-add NET_ADMIN。排查时先看lsmod | grep tun,再dmesg | grep tun,两步能挡住九成问题。
4.2 “虚拟机安装没有虚拟网卡”:把 virtio-net 和 TUN 的关系理清
现象:QEMU/KVM 虚拟机装完系统后,里面只有一块默认的 e1000 网卡,看不到额外创建的虚拟网卡,或者虚拟机网络不通。
原因:很多人把宿主机上的tun0和虚拟机里的虚拟网卡混为一谈。tun0是宿主机的端点,虚拟机里看到的是 virtio-net 或 e1000 虚拟设备,两者通过-netdev tap这条线连起来。如果 QEMU 命令行只创建了tun0而没有把它作为-netdev传给虚拟机,虚拟机自然感知不到。
解决:QEMU 启动参数里要显式把宿主机的 tap 端点接给虚拟机。常见做法是:
qemu-system-x86_64 \ -netdev tap,id=net0,ifname=tap0,script=no,downscript=no \ -device virtio-net-pci,netdev=net0这里ifname=tap0是宿主机上已经建好的 TAP 接口,script=no表示不让 QEMU 自动调用 up 脚本。虚拟机里看到的是 virtio-net 驱动,宿主机上看到的是 tap0,两者是同一根虚拟网线的两端。排查时先确认宿主机ip link show tap0是 up 的,再在虚拟机里ethtool eth0看链路状态,就能定位是哪一端断了。
4.3 insmod 报 invalid module format:vermagic 是最后一道密码锁
现象:insmod tun.ko直接报错误invalid module format,dmesg里能看到version magic不匹配的提示。
原因:内核模块加载时会校验收模块编译时的 vermagic,包括内核版本、SMP 开关、mod_unload 标志等。用主线源码编出来的模块去加载打了发行版补丁的内核,vermagic 几乎必然对不上。
解决:先uname -r看运行内核版本,再modinfo tun.ko | grep vermagic,逐项对比。最简单的做法是把源码换成发行版对应的linux-source包重新编译。如果一定要用主线源码,可以在.config里关掉CONFIG_MODULE_SIG和CONFIG_MODVERSIONS后重新make modules_prepare,但跨小版本编译依然可能因为结构体变动编不过。我一般不在这上面死磕,换对应版本源码是成本最低的路。
4.4 接口起了但计数器不动:MTU 与 txqueuelen 的配合
现象:ip link show tun0显示 up,地址也配了,但另一端 ping 不通,ifconfig tun0的 RX/TX 计数一直为 0,或者只涨一个方向。
原因:先分清是收包不动还是发包不动。TX 计数涨、RX 不动,说明用户态程序没在读 fd,报文在队列里积压;RX 涨、TX 不动,说明协议栈收到了包但路由/转发有问题,或者对端的回包没有走上这个接口。另外 MTU 不匹配表现为小包通、大包丢,txqueuelen太小则表现为突发流量时 TX 计数猛涨但 RX 掉队。
解决:用cat /proc/net/dev看tun0的rx_dropped和tx_dropped字段。
cat /proc/net/dev | grep tun0tx_dropped涨说明用户态消费速度跟不上,调大txqueuelen或优化用户态读取逻辑;rx_dropped涨说明协议栈处理不过来或 GRO 合并异常,考虑关 GRO。逐方向看计数器是定位虚拟网卡问题最快的手段,比瞎猜配置管用得多。
4.5 Windows 设备管理器错误 43:虚拟网卡驱动的签名与版本问题
现象:Windows 下安装第三方虚拟网卡驱动后,设备管理器里能看到设备,但状态显示黄色感叹号,“该设备无法启动(代码 43)”。
原因:Windows 对内核驱动有强制签名要求。过期签名的驱动、未经 WHQL 认证的驱动,在较新 Windows 版本上都会被直接拦下,表现为代码 43,而不是安装时报错。你手里的“原版”源码如果是多年前签名的二进包,大概率在这里翻车。
解决:先看驱动文件的数字签名是否有效,右键 -> 属性 -> 数字签名里能查到签名时间。如果只是开发调试,可以用bcdedit /set testsigning on开启测试签名模式后重启,但这条路只适合开发机,生产环境不建议长期开着。正式场景的正确做法是走正规签名流程:用 EV 证书对驱动进行交叉签名,或者提交给微软做 WHQL 认证。网上很多“原版”驱动包之所以装上就 43,不是因为代码有问题,而是签名这一关没过。
5. 验证驱动是否真的能干活:最小收发程序与吞吐压测
5.1 写一个最小 C 收发程序,先让驱动“动起来”
第 3 章里的示例程序已经能读包了,但还不够直观。我一般会再加一小段逻辑:收到 IP 包后原样写回 fd,做一个最简单的“回环网卡”。这样不需要第二台机器,单机就能验证双向路径。
/* 在上一个例子的 read 循环里加一行 */ while ((n = read(fd, buf, sizeof(buf))) > 0) { /* 原样回写,模拟网卡把包发回协议栈 */ write(fd, buf, n); }加了这行后,ping 192.168.77.1(你程序所在主机的 tun0 地址)时,ICMP 请求进驱动、被用户态读到、再原样写回、协议栈收到回包——如果 ping 通,说明收包和发包两条路径都正常工作。我第一次做这个验证时,发现 ping 通但接口的 TX 计数不涨,后来才意识到程序把包回写后,计数走的是 RX 路径的统计。看计数器时别只看名字,要看实际方向。
5.2 用 /proc/net/dev 与 ethtool -S 定位丢包方向
驱动“动了”之后,下一步是量化性能,先看丢包发生在哪一段。
# 看接口级统计 cat /proc/net/dev | grep tun0 # 看驱动队列级统计 ethtool -S tun0/proc/net/dev只区分rx_和tx_方向,rx_dropped高说明驱动收包后协议栈没接住,tx_dropped高说明用户态没及时取包。ethtool -S能看到每个队列的tun_tx_dropped和tun_rx_dropped计数,多队列场景下能判断是不是所有流量都压在了队列 0。丢包方向定位准了,再回头调 MTU、队列长度或用户态线程数,就不会瞎调了。
5.3 两台宿主机的 tun0 对压:iperf3 压测前的三件准备
单机回环验证不了真实吞吐。要压测,我通常在两台机器上各建一个 tun0,构成一个完整链路:
# 主机 A ip tuntap add dev tun0 mode tun ip addr add 192.168.77.1/24 dev tun0 ip link set tun0 up ip route add 192.168.77.2/32 dev tun0 # 主机 B ip tuntap add dev tun0 mode tun ip addr add 192.168.77.2/24 dev tun0 ip link set tun0 up ip route add 192.168.77.1/32 dev tun0两端的用户态程序都要在跑,否则包没人读。压测前有三件准备:第一,ping 192.168.77.2先通,链路不通时不要碰 iperf;第二,两端把 MTU 显式设成一致,我自己习惯先统一设 1500,测出基线再往上调;第三,决定 GRO/GSO 的开关状态,性能测试要么全开要么全关,混着开测出来的数据没有参考价值。都确认后一端跑iperf3 -s,另一端跑iperf3 -c 192.168.77.2。
压测结果出来先别急着高兴,回到/proc/net/dev看丢包。如果吞吐高但tx_dropped也在涨,说明用户态程序是瓶颈;如果吞吐低且rx_dropped高,说明协议栈或你的应用逻辑是瓶颈。虚拟网卡驱动的优化方向基本都是这三条路:多队列分摊、GRO 开关、txqueuelen 加长,没有第四条捷径。
最后说一个我自己的习惯:每次验证虚拟网卡驱动,我都会留一张小纸条,上面写着“先 ping,再 iperf,最后看 /proc/net/dev”。因为早年间我犯过一个非常蠢的错——接口建好了、程序跑起来了、iperf 压了几轮,数据很难看,折腾一晚才发现对端地址配错了,数据全在走物理网卡回环。从那以后,我再也不跳过 ping 这一步了。这个顺序能帮你把链路一段段拆开验证,哪一段出问题,哪一段的计数器会先说话。希望帮到你。
本文还有配套的精品资源,点击获取