Linux虚拟网卡驱动源码解析:从tun.c编译到TUN/TAP故障排查
简介一份源自《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网络层L3IP 报文不含以太网头跨主机路由、自定义协议封装、流量采集点对点接口没有 MAC 地址TAP数据链路层L2完整以太网帧含 MAC 头虚拟机接入网桥、容器 macvlan、二层隔离标准以太网接口有 MAC 地址用ip tuntap add dev tun0 mode tun创建的是 TUNmode 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 Mdrivers/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 permittedsudo运行即可。程序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,idnet0,ifnametap0,scriptno,downscriptno \ -device virtio-net-pci,netdevnet0这里ifnametap0是宿主机上已经建好的 TAP 接口scriptno表示不让 QEMU 自动调用 up 脚本。虚拟机里看到的是 virtio-net 驱动宿主机上看到的是 tap0两者是同一根虚拟网线的两端。排查时先确认宿主机ip link show tap0是 up 的再在虚拟机里ethtool eth0看链路状态就能定位是哪一端断了。4.3 insmod 报 invalid module formatvermagic 是最后一道密码锁现象insmod tun.ko直接报错误invalid module formatdmesg里能看到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 这一步了。这个顺序能帮你把链路一段段拆开验证哪一段出问题哪一段的计数器会先说话。希望帮到你。本文还有配套的精品资源点击获取