TUN / TAP 虚拟网卡技术详解:三层隧道与二层网桥的取舍
八月 16, 2026 [网络与安全] #tun #Linux #vpn #qemu #tailscale背景:本机网络栈里那些看不见的"网卡"
日常 ip link 会看到一堆奇怪的接口——tun0、tap0、tailscale0、docker0、veth。它们不是物理网卡,而是内核虚拟设备。本机(Proxmox 宿主)实测:
tailscale0: <POINTOPOINT,MULTICAST,NOARP> ... ← TUN,三层
inet 100.64.0.3/32 scope global tailscale0
tun0: <POINTOPOINT,MULTICAST,NOARP> ... ← TUN,OpenVPN
inet 10.8.0.6 peer 10.8.0.5/32 scope global tun0
vmbr0: ... ← 网桥,二层
TUN 和 TAP 是 Linux 提供的一对"用户空间 ↔ 内核协议栈"的虚拟管道设备:程序打开 /dev/net/tun,通过 read/write 收发数据包。区别只在收发的是什么东西。
TUN 与 TAP 的基本概念
| 特性 | TUN | TAP |
|---|---|---|
| 工作层级 | 三层(网络层) | 二层(数据链路层) |
| 处理的数据 | IP 数据包 | 以太网帧 |
| 有 MAC 地址? | 没有(link/none) | 有(link/ether) |
| 有 IP 地址? | 有(手动配置) | 有(手动配置) |
| 能加入网桥? | 不能 | 可以 |
| 典型应用 | VPN(OpenVPN、WireGuard、Tailscale/Headscale) | 虚拟机网络(QEMU/KVM) |
TUN 设备详解:三层、无 MAC、点对点
基本属性
TUN 是三层(网络层)设备,只处理 IP 包,不涉及以太网帧,因此没有 MAC 地址:
# TUN 设备 —— link/none,没有 MAC
5: tun0: <POINTOPOINT,MULTICAST,NOARP> ...
link/none
# ifconfig 视角:硬件地址全是零
tun0 Link encap:UNSPEC HWaddr 00-00-00-00-00-00-...
inet addr:10.8.0.5 P-T-P:10.8.0.5 Mask:255.255.255.0
UP POINTOPOINT RUNNING NOARP MULTICAST
为什么不需要 MAC?
TUN 工作在三层,数据直接以 IP 包形式在内核协议栈和用户空间程序之间传递,不经过以太网帧封装,自然不需要 MAC 做二层寻址。
POINTOPOINT(点对点)标志揭示了它的本质:一条虚拟点对点链路——一端是宿主机内核协议栈,另一端是用户空间的 VPN 程序。数据通过这条管道直接传递 IP 包,不存在二层寻址的概念。
这也是 Tailscale/OpenVPN 等 VPN 选择 TUN 的根本原因:省去 ARP、MAC、广播等二层烦恼,VPN 程序只需专注"路由 IP 包 → 加密 → UDP/TCP 发到远端"。本机 tailscale0 就是 TUN,100.64.0.3/32 点对点。
TAP 设备详解:二层、必须"有"MAC、喂给网桥
创建与配置
sudo ip tuntap add dev tap0 mode tap user $(whoami) # user 参数允许普通用户读写
sudo ip link set tap0 up
sudo ip addr add 10.0.0.1/24 dev tap0
MAC 地址的两个"参与者"必须不同
- tap0 自身的 MAC:创建时内核随机生成(常以
fe:开头),只是占位符,不是虚拟机的 MAC。 - 虚拟机网卡的 MAC:QEMU 打开 tap0 后,在 VM 内部模拟网卡硬件(virtio-net),分配自己的 MAC(常以
52:54:开头;可用-device virtio-net-pci,netdev=net0,mac=52:54:00:12:34:56固定)。
两者必须不同,原因在网桥:br0 依靠 MAC 地址表决定帧的转发端口,若 tap0 与 VM 网卡 MAC 相同,网桥将无法判断该 MAC 对应的设备在 tap0 端口侧还是外部物理网络侧,转发混乱。libvirt 的规律:把 VM MAC 首字节改为 0xFE 生成 tap0 的 MAC(如 VM 52:54:d7:18:09:2a → tap0 fe:54:d7:18:09:2a)。
tap0 的 MAC 几乎没有实际作用
回顾数据流:网桥学习的是帧里的源 MAC(VM 的),不是 tap0 接口自身的 MAC;帧经过 tap0 时 MAC 不被替换;TAP 设备设了 NOARP,没人 ARP 查询它。那为什么它还存在?——Linux 内核 net_device 结构体的硬性要求:任何网络设备都必须有一个 MAC 地址,即使实际用不到。它是"存在即合理"的占位符。
数据包如何路由到虚拟机:网桥的 MAC 表动态学习
数据包路由到 VM 依赖的是网桥 MAC 转发表,由 VM 发出帧的源 MAC 动态学习建立:
初始:br0 转发表为空
VM1 发帧(src MAC 52:54:d7:18:09:2a)从 tap0 进入
→ 网桥记录:52:54:d7:18:09:2a → tap0 端口
VM2 发帧(src MAC 52:54:aa:bb:cc:dd)从 tap1 进入
→ 网桥记录:52:54:aa:bb:cc:dd → tap1 端口
回包:外部帧 dst=52:54:d7:18:09:2a
→ 网桥查表 → 从 tap0 端口转发 → QEMU 读帧注入 VM1
数据包交互流程:VM → 外网
拓扑:VM(eth0 10.0.0.2) ↔ QEMU 进程 ↔ 宿主机 tap0 ↔ 网桥 br0(10.0.0.1) ↔ 物理网卡 eth0 ↔ 外网。
发送(VM1 → 外网):
VM1 应用 → VM1 内核查路由表 → 虚拟网卡 eth0
→ QEMU 进程把帧写入宿主机 tap0
→ 内核发现 tap0 挂在 br0 上,转发到网桥
→ br0 查 MAC 表,目标不在本地 VM → 从物理网卡 eth0 发出
→ (VM 访问外网通常还需宿主机 NAT:iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE)
→ 路由器 → 外网目标
接收(外网 → VM1):对称反向——物理网卡收帧 → br0 查 MAC 表找到 tap0 端口 → 帧交给 QEMU → 注入 VM 虚拟网卡 → VM 内核协议栈 → 应用。
核心要点:
- TAP 是桥梁:一头连内核网桥,一头被 QEMU 持有,负责搬运帧
- 网桥 br0 是交换机:二层按 MAC 决定转发端口
- NAT 是可选的:VM 要出外网才需要 MASQUERADE
- QEMU 是搬运工:read/write tap0 与 VM 网卡之间持续搬运
50 行 Python 简易 VPN:验证"VPN 本质"
核心逻辑:打开 /dev/net/tun → ioctl 配置为 tap0 → 死循环读包/写包。示例程序只处理 ARP 请求,构造假回复声称"10.0.0.2 存在":
import os, struct
from fcntl import ioctl
TUNSETIFF = 0x400454ca
IFF_TAP = 0x0002
IFF_NO_PI = 0x1000
def create_tap(ifname):
tap_fd = os.open("/dev/net/tun", os.O_RDWR)
ifr = struct.pack("16sH", ifname.encode("utf-8"), IFF_TAP | IFF_NO_PI)
ioctl(tap_fd, TUNSETIFF, ifr)
return tap_fd
def handle_arp_request(packet, tap_fd):
host_mac = packet[6:12] # 提取 ARP 请求源 MAC
fake_mac = bytes([0x00,0x11,0x22,0x33,0x44,0x55])
arp_reply = (host_mac + fake_mac + b'\x08\x06' + b'\x00\x01' + b'\x08\x00' +
b'\x06' + b'\x04' + b'\x00\x02' + fake_mac +
b'\x0a\x00\x00\x02' + host_mac + b'\x0a\x00\x00\x01')
os.write(tap_fd, arp_reply)
print("捕获到 ARP 请求,已伪造 10.0.0.2 的回复!")
def main():
tap_fd = create_tap("tap0")
print("启动简易 VPN 程序,正在监听 tap0...")
while True:
packet = os.read(tap_fd, 1514)
if packet[12:14] == b'\x08\x06': # ARP
handle_arp_request(packet, tap_fd)
else:
pass # 其他包(IP)直接丢弃
if __name__ == "__main__":
main()
运行与现象:python3 simple_vpn.py + 另一终端 ping 10.0.0.2——先通(ARP 被拦截伪造了假回复),随后 Destination Host Unreachable 或卡住。为什么卡住:ping 拿到假 MAC 后发出 ICMP IP 包,程序在 else 分支直接丢弃——没有完整 TCP/IP 协议栈就回不了 ICMP Echo Reply。
这揭示了 VPN 的本质:
- TAP 模式:读的是完整以太网帧,要实现完整网络功能,程序内部必须实现一套虚拟协议栈(ARP/ICMP/TCP 握手),或把帧转发到真实物理网络——复杂
- TUN 模式(Tailscale 用):读的是纯 IP 包(无 MAC 头),省去 ARP 烦恼,VPN 程序只需路由、加密、UDP/TCP 传输——简单,这正是 tailscale0 走的路
各虚拟化平台的 TAP/TUN 管理
| 场景 | 需要手动创建? | 说明 |
|---|---|---|
| QEMU/KVM 手动配置 | 需要 | 每个 VM 一个 TAP |
| libvirt / virt-manager | 自动 | libvirt 自动管理 |
| VMware / VirtualBox | 自动 | 工具内部自动处理 |
| Docker | 自动 | 用 veth pair,非 TAP |
| Tailscale/Headscale | 自动 | 每节点自动创建自己的 TUN |
总结
- TUN = 三层点对点管道,无 MAC,只管 IP 包 → VPN 首选(OpenVPN/WireGuard/Tailscale)
- TAP = 二层以太网管道,有 MAC(内核硬性要求,实际是占位符),能进网桥 → 虚拟机网络首选(QEMU/KVM)
- 网桥靠帧内源 MAC 动态学习转发表,与 tap0 自身 MAC 无关
- "为什么 VPN 不用 TAP":二层帧需要完整协议栈或桥接转发,三层 TUN 省掉 ARP/MAC/广播,实现和排障都简单得多
- 虚拟化为什么不用 TUN:VM 需要完整二层语义(广播/ARP/交换机行为),TAP + 网桥正是干这个的