当前位置:首页>Linux>深入理解 Linux tap/tun 驱动工作原理

深入理解 Linux tap/tun 驱动工作原理

  • 2026-09-11 01:12:25
深入理解 Linux tap/tun 驱动工作原理
当你用 OpenVPN 连接公司内网、用 Docker 跑容器、用 KVM 启动虚拟机时,背后都有一个共同的底层技术在默默工作——它就是 Linux 的 tap/tun 驱动。本文将带你从内核视角彻底搞懂它的工作原理。

目录

  1. 从一个问题说起
  2. Linux 网络栈速览
  3. TUN 设备:三层虚拟网卡
  4. TAP 设备:二层虚拟网卡
  5. 数据包流转全过程
  6. 内核源码视角
  7. 代码实战
  8. 典型应用场景
  9. 总结

一、从一个问题说起

假设你有一台 Linux 服务器,上面跑着一个用户态程序。这个程序想要直接收发 IP 数据包——不是通过 TCP socket,不是通过 raw socket,而是像一张真实的物理网卡一样,成为网络栈的一部分。

听起来有点疯狂?但这恰恰是 VPN、虚拟机网络、容器网络等场景的核心需求。Linux 内核给出的答案是:给你一张虚拟网卡。

这张虚拟网卡就是 tun 或 tap 设备。它们看起来像真实的网卡(有 IP 地址、有路由、可以配置 MTU),但数据的收发不是通过网线,而是通过一个字符设备文件(/dev/net/tun)在内核和用户态之间传递。


二、Linux 网络栈速览

要理解 tap/tun,先得知道数据包在 Linux 网络栈中是怎么走的。简化后大致是这样的:

关键点在于:TUN 设备接入的是 IP 层(L3),而 TAP 设备接入的是 链路层(L2)。它们都通过同一个字符设备 /dev/net/tun 与用户态程序通信。


三、TUN 设备:三层虚拟网卡

TUN(network TUNnel)模拟的是一台网络层设备。它只处理 IP 数据包(Layer 3),不关心以太网帧头。

发送方向:应用程序 → tun 设备 → 用户态程序

当一个数据包要发往 tun 设备时,内核网络栈会经过路由判断,发现目标 IP 应该从 tun 设备出去。于是 IP 层将数据包交给 tun 设备的发送函数。但 tun 没有真正的网线——它把数据包放进了自己的待读队列(即用户态 read() 取包的字符设备队列),等待用户态程序来取走。

接收方向:用户态程序 → tun 设备 → 内核网络栈

用户态程序通过 write() 往 tun 设备写一个 IP 包。内核会将这个包注入到网络栈的接收路径,就好像这张虚拟网卡真的从网线上收到了一个包一样。之后的处理与真实网卡完全相同:路由查找、Netfilter 过滤、协议分发。


四、TAP 设备:二层虚拟网卡

TAP(network TAP)模拟的是一台链路层设备。它处理的是以太网帧(Layer 2),包含完整的 MAC 地址头。

和 TUN 的区别在于数据粒度:

特性
TUN
TAP
工作层级
网络层 (L3)
链路层 (L2)
数据单元
IP 数据包
以太网帧(含 MAC 头)
是否处理 ARP
否
是
是否有 MAC 地址
有(收发不依赖 MAC)
有
开销
较小
较大(多一层帧头)
典型应用
IP 隧道 / VPN
虚拟机网络 / 桥接

选择建议:如果你只需要传递 IP 包(比如构建一个 IP VPN),用 TUN,开销更小。如果你需要完整的以太网功能(比如虚拟机需要 ARP、STP、广播),用 TAP。


五、数据包流转全过程

让我们完整追踪一个数据包的旅程。场景:用户态 VPN 程序通过 TUN 设备接收一个发往 10.0.0.0/24 的数据包,加密后通过真实网卡发出去。

发送路径:内核 → TUN → 用户态 → 物理网卡

  1. 内核路由表有一条规则:10.0.0.0/24 dev tun0。一个发往 10.0.0.5 的数据包命中这条路由。
  2. IP 层调用 tun_net_xmit(),数据包被放入 TUN 字符设备的待读队列,同时唤醒阻塞在 read() 上的用户态进程。
  3. VPN 程序从 read(fd, buf, size) 拿到原始 IP 数据包,对它进行加密(如 AES)、封装(如 UDP 封装),然后通过普通 socket 发送到对端 VPN 服务器。
  4. 封装后的数据包走正常网络栈路径,通过物理网卡 eth0 发出。

接收路径:物理网卡 → 用户态 → TUN → 内核

  1. 对端 VPN 服务器返回加密数据包,通过 eth0 进入内核,经过 IP 层处理,最终到达 VPN 程序的 socket。
  2. VPN 程序从 socket recv() 收到数据,解密、拆封,还原出原始 IP 数据包。
  3. VPN 程序通过 write(fd, buf, size) 将原始 IP 包写入 TUN 设备。内核在 tun_chr_write_iter() 中接收数据包,调用 netif_rx_ni()(进程上下文专用版本)将其注入网络栈的接收路径。
  4. 数据包像从真实网卡收到的一样,经过路由查找,最终被本机应用程序的 socket 接收,或继续转发到其他网络。

六、内核源码视角

tap/tun 驱动的代码在内核源码 drivers/net/tun.c 中。整个驱动围绕两个核心接口展开。

“

网络设备接口(发送方向)

每个网络设备都需要注册一个 net_device_ops 结构体,其中最重要的是 ndo_start_xmit——当内核网络栈要发送数据包时,就会调用这个函数。

// drivers/net/tun.c (简化版)staticconststructnet_device_opstun_netdev_ops = {    .ndo_start_xmit      = tun_net_xmit,    .ndo_open            = tun_net_open,    .ndo_stop            = tun_net_close,    .ndo_change_mtu      = tun_net_change_mtu,};/* * 内核网络栈调用此函数将数据包"发送"到 tun 设备。 * 但 tun 没有网线,所以实际做法是: * 1. 将 sk_buff 放入 tun 字符设备的待读队列 *    (即用户态 read() 读取的队列) * 2. 唤醒等待在 read() 上的用户态进程 */staticnetdev_tx_ttun_net_xmit(struct sk_buff *skb,struct net_device *dev){structtun_struct *tun = netdev_priv(dev);// 1. 将 skb 加入 tun 字符设备的待读队列    skb_queue_tail(&tun->queue, skb);// 2. 唤醒阻塞在 read() 上的进程    wake_up_interruptible(&tun->wq.wait);return NETDEV_TX_OK;}

字符设备接口(用户态交互)

用户态通过 /dev/net/tun 与内核交互。read() 从队列取数据包,write() 向网络栈注入数据包。

// 用户态 read() -> 内核调用此函数staticssize_ttun_chr_read_iter(struct kiocb *iocb,struct iov_iter *to){structtun_struct *tun = file_to_tun(file);// 从队列取出一个数据包(先尝试非阻塞取包)structsk_buff *skb = skb_dequeue(&tun->queue);if (!skb) {// 队列为空,进程进入睡眠等待唤醒// (真实代码此处有 spin_lock 保护队列,//  并循环处理虚假唤醒,这里为展示逻辑已省略)        wait_event_interruptible(tun->wq.wait,            skb_queue_len(&tun->queue) > 0);        skb = skb_dequeue(&tun->queue);    }// 将 sk_buff 中的数据拷贝到用户态缓冲区    copy_to_iter(skb->data, skb->len, to);    kfree_skb(skb);return skb->len;}// 用户态 write() -> 内核调用此函数staticssize_ttun_chr_write_iter(struct kiocb *iocb,struct iov_iter *from){structtun_struct *tun = file_to_tun(file);// 从用户态拷贝数据structsk_buff *skb = alloc_skb(len, GFP_KERNEL);    copy_from_iter(skb->data, len, from);// 设置 skb 的输入设备为 tun    skb->dev = tun->dev;// 根据模式设置协议类型(关键区别!)if (tun->flags & IFF_TAP) {// TAP 模式:数据从以太网帧头开始,// 由 eth_type_trans 从帧头解析协议类型        skb->protocol = eth_type_trans(skb, tun->dev);    } else {// TUN 模式:数据直接从 IP 头开始(无 MAC 头),// 绝不能调用 eth_type_trans(会把 IP 头误当以太网头),// 而是按 IP 头首字节的高 4 位版本号判断 IPv4/IPv6        skb->protocol = (skb->data[0] >> 4) == 6 ?                        htons(ETH_P_IPV6) : htons(ETH_P_IP);    }// 关键!将数据包注入内核网络栈接收路径。// 注意:此处运行在进程上下文(系统调用),// 必须用 netif_rx_ni() 而非 netif_rx()(后者用于中断上下文)    netif_rx_ni(skb);return len;}
“

创建设备:ioctl TUNSETIFF

用户态程序创建 TUN/TAP 设备的入口是 ioctl(fd, TUNSETIFF, &ifr):

// 内核处理 TUNSETIFF ioctlstaticinttun_set_iff(struct file *file, struct ifr __user *argp){structtun_struct *tun;structnet_device *dev;// 分配 net_device,注册 net_device_ops    dev = alloc_netdev(sizeof(*tun), name,                           NET_NAME_UNKNOWN, tun_setup);// 根据标志选择 TUN 还是 TAPif (ifr.ifr_flags & IFF_TUN) {// TUN 模式:L3,无以太网头        dev->netdev_ops = &tun_netdev_ops;    } elseif (ifr.ifr_flags & IFF_TAP) {// TAP 模式:L2,有以太网头        dev->netdev_ops = &tap_netdev_ops;    }// 注册到内核网络设备列表    register_netdevice(dev);// 将 tun_struct 与文件描述符关联    tun = netdev_priv(dev);    file->private_data = tun;return0;}

七、代码实战

下面是一个最简的 TUN 设备使用示例——创建一个 TUN 设备,读取数据包并打印 IP 头信息:

#include<linux/if.h>#include<linux/if_tun.h>#include<linux/ip.h>#include<sys/ioctl.h>#include<fcntl.h>#include<unistd.h>#include<string.h>#include<stdio.h>#include<arpa/inet.h>inttun_alloc(char *dev, int flags) {structifreqifr;int fd, err;// 打开通用 TUN/TAP 字符设备    fd = open("/dev/net/tun", O_RDWR);if (fd < 0) {        perror("open /dev/net/tun");return-1;    }memset(&ifr, 0, sizeof(ifr));    ifr.ifr_flags = flags;  // IFF_TUN 或 IFF_TAPif (*dev)strncpy(ifr.ifr_name, dev, IFNAMSIZ);// 通过 ioctl 创建虚拟网卡    err = ioctl(fd, TUNSETIFF, &ifr);if (err < 0) {        perror("ioctl TUNSETIFF");        close(fd);return-1;    }strcpy(dev, ifr.ifr_name);return fd;}intmain() {char tun_name[IFNAMSIZ] = "tun0";char buf[2000];int n, fd;// 创建 TUN 设备 (L3 模式)。// IFF_NO_PI: 不附带 4 字节 Packet Info 头,// read/write 直接是 IP 包数据(默认会带 PI 头)    fd = tun_alloc(tun_name, IFF_TUN | IFF_NO_PI);if (fd < 0) return1;printf("TUN 设备 %s 已创建\n", tun_name);printf("请运行: sudo ip addr add ""10.0.0.1/24 dev tun0 && ""sudo ip link set tun0 up\n");printf("然后: ping 10.0.0.2\n\n");// 循环读取数据包while (1) {        n = read(fd, buf, sizeof(buf));if (n < 0) {            perror("read");break;        }// 解析 IP 头 (TUN 模式下数据从 IP 头开始)。// 用 struct iphdr 读取,兼容带选项字段的 IP 头structiphdr *ip = (struct iphdr *)buf;char src[32], dst[32];        inet_ntop(AF_INET, &ip->saddr, src, sizeof(src));        inet_ntop(AF_INET, &ip->daddr, dst, sizeof(dst));printf("[%d bytes] protocol=%d %s -> %s\n",               n, ip->protocol, src, dst);    }    close(fd);return0;}

编译运行后,你会看到类似这样的输出:

TUN 设备 tun0 已创建请运行: sudo ip addr add 10.0.0.1/24 dev tun0 && sudo ip link set tun0 up然后: ping 10.0.0.2[84 bytes] protocol=1 10.0.0.1 -> 10.0.0.2[84 bytes] protocol=1 10.0.0.1 -> 10.0.0.2[84 bytes] protocol=1 10.0.0.1 -> 10.0.0.2

protocol=1 就是 ICMP(ping)。内核路由发现 10.0.0.2 应该走 tun0,于是 IP 包被送入 TUN 设备,我们的程序通过 read() 收到了它。


八、典型应用场景

1. VPN 隧道(OpenVPN, WireGuard)

VPN 程序创建一个 TUN 设备,配置路由让特定网段的流量走 TUN。程序从 TUN 读取 IP 包,加密后通过物理网卡发出;收到对端返回的加密数据后,解密还原为 IP 包,写入 TUN 设备注入内核网络栈。

2. 虚拟机网络(KVM/QEMU)

QEMU 为每台虚拟机创建一个 TAP 设备,虚拟机的虚拟网卡(virtio-net)通过 TAP 与宿主机网络栈通信。宿主机可以把多个 TAP 设备桥接到一个 linux bridge 上,实现虚拟机之间的二层互通。

3. 容器网络

一些容器网络方案(如早期的 flannel UDP 模式)使用 TUN 设备在容器之间转发数据包。更现代的方案通常使用 VETH pair,但 TAP/TUN 仍然是底层基础设施之一。


九、总结

tap/tun 的核心思想非常优雅:用字符设备模拟网络设备。它巧妙地在内核网络栈和用户态程序之间架了一座桥梁:

  • TUN
     = 三层虚拟网卡,传递 IP 包,适合 VPN 等三层隧道
  • TAP
     = 二层虚拟网卡,传递以太网帧,适合虚拟机桥接
  • 发送路径
    :内核网络栈 → ndo_start_xmit → 入队 → 用户态 read()
  • 接收路径
    :用户态 write() → netif_rx_ni() → 注入网络栈接收路径
  • 统一入口
    :/dev/net/tun 字符设备 + TUNSETIFF ioctl
“

希望这篇文章帮助你建立了对 tap/tun 驱动的直觉。如果想更深入,建议阅读内核源码 drivers/net/tun.c,以及动手跑一跑上面的示例代码——当你看到第一个 ICMP 包从 TUN 设备里读出来的时候,一切就真正串起来了。


— END —

最新文章

随机文章