当你用 OpenVPN 连接公司内网、用 Docker 跑容器、用 KVM 启动虚拟机时,背后都有一个共同的底层技术在默默工作——它就是 Linux 的 tap/tun 驱动。本文将带你从内核视角彻底搞懂它的工作原理。
目录
一、从一个问题说起
假设你有一台 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 的区别在于数据粒度:
选择建议:如果你只需要传递 IP 包(比如构建一个 IP VPN),用 TUN,开销更小。如果你需要完整的以太网功能(比如虚拟机需要 ARP、STP、广播),用 TAP。
五、数据包流转全过程
让我们完整追踪一个数据包的旅程。场景:用户态 VPN 程序通过 TUN 设备接收一个发往 10.0.0.0/24 的数据包,加密后通过真实网卡发出去。
发送路径:内核 → TUN → 用户态 → 物理网卡
- 内核路由表有一条规则:
10.0.0.0/24 dev tun0。一个发往 10.0.0.5 的数据包命中这条路由。 - IP 层调用
tun_net_xmit(),数据包被放入 TUN 字符设备的待读队列,同时唤醒阻塞在 read() 上的用户态进程。 - VPN 程序从
read(fd, buf, size) 拿到原始 IP 数据包,对它进行加密(如 AES)、封装(如 UDP 封装),然后通过普通 socket 发送到对端 VPN 服务器。 - 封装后的数据包走正常网络栈路径,通过物理网卡
eth0 发出。
接收路径:物理网卡 → 用户态 → TUN → 内核
- 对端 VPN 服务器返回加密数据包,通过
eth0 进入内核,经过 IP 层处理,最终到达 VPN 程序的 socket。 - VPN 程序从 socket
recv() 收到数据,解密、拆封,还原出原始 IP 数据包。 - VPN 程序通过
write(fd, buf, size) 将原始 IP 包写入 TUN 设备。内核在 tun_chr_write_iter() 中接收数据包,调用 netif_rx_ni()(进程上下文专用版本)将其注入网络栈的接收路径。 - 数据包像从真实网卡收到的一样,经过路由查找,最终被本机应用程序的 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 —