TAP/TUN与自定义网络协议栈

发布时间:2026/6/23 15:59:48
TAP/TUN与自定义网络协议栈 这个文章对TAP/TUN讲的比较清楚https://blog.csdn.net/tjcwt2011/article/details/160653673《深入高可用系统原理与设计》https://www.thebyte.com.cn/network/tuntap.html一、在用户空间实现自定义网络协议栈核心思想内核协议栈是个黑盒——你想改 TCP 拥塞控制算法想试一种新的传输协议以前只能改内核源码、重新编译。TUN/TAP 彻底改变了这件事把协议栈从内核里搬到用户空间用普通程序实现而无需修改一行内核代码。工作原理你的用户态协议栈 ──write──→ /dev/net/tun (TAP设备) │ 内核协议栈收到一个以太网帧 │ 当作从真实网卡收到的一样处理 │ 路由查找 → 可能转发到物理网卡发出 反过来 物理网卡收到包 → 内核协议栈 → 路由判定发往 TAP → 你的程序 read() 收到你的程序既是发送方也是接收方完全控制每个字节怎么处理。典型项目都是真实可跑的项目做了什么用的模式microps轻量级 TCP/IP 协议栈专为学习设计TAPtapip用户态 TCP/IP 实现GitHub 热门TAPlevel-ip完整的用户态协议栈支持 curlTAP以microps为例实际操作流程# 1. 创建 TAP 设备sudoiptuntapadddev tap0 mode tap# 2. 配置 IP让宿主机能访问这个虚拟网络sudoipaddradd10.0.0.1/24 dev tap0 --内核能看到这个地址对microps协议栈而言这是网关IPsudoiplinksetdev tap0 up# 3. 运行你自己写的协议栈sudo./app/tcps-itap0# 启动一个 TCP 服务器# 4. 从另一个终端连接测试telnet10.0.0.28080# 真的能通# 5. Wireshark 抓 tap0看到的就是你协议栈发出的帧为什么这对学习/研究有巨大价值传统方式改内核TUN/TAP 方式改一行代码 → 重新编译内核 → 重启 → test改一行代码 → 重新编译程序 → 重跑 → 立即看到结果崩溃 内核 panic机器挂了崩溃 程序退出宿主机 unaffected无法用 Wireshark 抓中间过程Wireshark 直接抓 TAP 接口每个包都能看只能在自己机器上测可以在任何 Linux 机器上跑零硬件成本一句话TUN/TAP 给了你一个完全隔离、可观测、可随意炸掉重建的网络沙盒。二、流量监控与过滤核心思想所有经过 TUN/TAP 的数据包用户态程序都能原样读到。这意味着你可以看到每个包的完整内容不像 iptables 只能看头部决定放行/丢弃/修改/重定向实现内核做不到的灵活策略两种部署模式模式原理典型工具软件 TAP本文重点创建虚拟接口把特定流量导过去httptap、自研过滤器硬件 Tap物理设备在交换机和设备之间串一个物理 Tap 盒商业网络嗅探方案实战案例httptap —— 监控任意程序的 HTTP/HTTPS 流量这是一个真实项目用法极其简单# 监控 curl 发出的所有 HTTP/HTTPS 请求httptap --curlhttps://example.com# 指定只看 80 和 443 端口httptap--http80,8080--https443,8443-- firefox# 导出为 HAR 文件后续用 Chrome DevTools 分析httptap --dump-har output.har -- your-app它的内部实现就是1. 创建 TUN 设备工作在 L3拿到 IP 包 2. 用户态程序自己实现一个 TCP/IP 栈gvisor 或 handrolled 3. 解析出 HTTP 请求/响应 4. 打印 / 存储 / 转发自定义防火墙策略怎么做逻辑非常直观// 伪代码一个基于 TUN 的用户态防火墙while(1){packetread(tun_fd,buf,sizeof(buf));// 从 TUN 读包if(packet.dst_ip1.2.3.4packet.dst_port443){drop(packet);// 丢包屏蔽这个 IP}elseif(packet.payload_contains(malware)){modify_and_forward(packet);// 修改后转发}else{write(tun_fd,packet,len);// 原样放回内核栈}}能力iptables/nftablesTUN/TAP 用户态基于 IP/端口过滤✅✅基于 payload 内容过滤❌ 做不到✅ 任意正则/字符串匹配修改包内容如重写 Host 头✅ NAT 能做一部分✅ 任意修改看到完整包内容包括 payload❌✅性能极高内核态较低用户态拷贝性能瓶颈在哪怎么优化瓶颈原因优化方案内核↔用户态拷贝每个包都要 copy_to_user / copy_from_user用TPACKET_V3 mmap 零拷贝httptap 就是这么做的用了 1000 大小的缓冲通道用户态协议栈慢自己实现 TCP 比内核慢得多用 gvisor 栈代替手写栈兼容性和性能都更好单线程处理一个 read() 循环扛所有包多线程 缓冲通道httptap 用chan []byte做缓冲一句话总结场景TUN/TAP 解决了什么自定义协议栈不改内核在用户空间造一个完整的 TCP/IP可炸可重建可观测流量监控过滤内核做不到的深度包检测DPI在用户态随便写代价是性能如果你是想学习协议选 TAP microps/tapip零成本开干。如果你是想监控特定应用的流量httptap 这种工具开箱即用。如果你要生产环境的高性能过滤TUN/TAP 不是最佳选择但它是最灵活的起点。microps 例子中tap0和真实物理网卡没有做桥接报文是如何从物理网卡发出去的靠内核路由表不需要 bridge。你的直觉是对的——tap0 和 eth0 之间确实没有二层连接。但它们能通靠的是三层路由不是二层桥接。完整路径长这样以 microps 发起一个到8.8.8.8的包为例microps 写包到 tap0 │ ▼ 内核收到 tap0 的以太网帧目的 MAC 是网关的 MAC │ ▼ 内核查路由表8.8.8.8 不在 10.0.0.0/24 → 走默认网关 │ ▼ 默认网关 192.168.1.1 → 下一跳从 eth0 出去 │ ▼ eth0 发出到达物理交换机 → 互联网反过来也一样互联网回包到达 eth0 │ ▼ 内核查路由表去 10.0.0.0/24 的路由 → 下一跳是 tap0 │ ▼ 包从 tap0 送到 microps 的 read()为什么能通三个关键条件说明✅ tap0 有 IPip addr add 10.0.0.1/24 dev tap0内核认为它是一个真实接口✅ 有默认路由ip route add default via 192.168.1.1 dev eth0告诉内核未知目的地走 eth0✅ ip_forward 开启sysctl net.ipv4.ip_forward1允许内核在接口间转发这就够了。内核根本不关心包是从哪个接口进来的它只查路由表。和 bridge 方案的本质区别microps 方案tun/tap 路由虚拟机方案tap bridge连接方式三层路由二层桥接tap0 有 IP 吗✅ 有10.0.0.1❌ 无eth0 有 IP 吗✅ 有192.168.1.50❌ 无IP 移到 br0 上了包怎么出去内核路由 → eth0bridge 转发 → eth0虚拟机能发 ARP 吗❌ 不能没有二层域✅ 能bridge 模拟真实交换机一句话tap0 不需要和 eth0 桥接因为内核路由表会自动把包从 tap0 导向 eth0。bridge 解决的是二层问题ARP/广播microps 这种场景根本不需要二层三层路由就够了。这其实就是我最早说的tun 只能工作在 L3但 L3 靠 ip_forward 完全可以和物理网卡连通。microps 方案中明明创建的是tap而不是tun为什么说是三层路由呢你这个问题问到点子上了。确实不矛盾但需要把谁在做二层、谁在做三层拆清楚。矛盾在哪你的理解我之前说的TAP 二层设备收发以太网帧这个方案靠三层路由通的→ 既然是二层应该靠 bridge 才对→ 桥接才是二层的事啊看起来矛盾但其实两句话说的是不同层的事。拆开看谁负责哪一层以 microps 发一个包到8.8.8.8为例microps 协议栈用户态 │ │ 1. 自己实现 ARP查到网关 MAC aa:bb:cc:dd:ee:ff │ 2. 自己构造完整以太网帧 │ ┌─────────────────────────────────────┐ │ │ eth_hdr: dstaa:bb:cc:dd:ee:ff │ ← microps 自己填的 MAC 头 │ │ ip_hdr: dst8.8.8.8 │ ← microps 自己实现的 IP 层 │ │ tcp_hdr: dst443 │ ← microps 自己实现的 TCP 层 │ └─────────────────────────────────────┘ │ 3. write() 写到 /dev/net/tap → tap0 │ ▼ 内核协议栈收到 tap0 的以太网帧 │ │ 4. 一看目的 MAC aa:bb:cc:dd:ee:ff网关→ 是给我的 │ 5. 剥掉以太网头拿到 IP 包查路由表 │ 8.8.8.8 不在本地 → 走默认网关从 eth0 出去 │ 6. 重新封装以太网帧dst MAC 真实网关 MACeth0 发出 │ ▼ 物理网卡 → 互联网层级谁在做用什么设备二层MAC/ARPmicrops 自己用户态协议栈TAP 只是个管道透传以太网帧三层IP 路由内核路由表不管包从 tap0 还是 eth0 进来内核都查同一张路由表所以为什么说三层路由因为包从 tap0 出来之后内核的处理方式和从 eth0 收到包完全一样剥二层头 → 查三层路由表 → 从另一个接口发出这就是纯三层路由和 bridge 没有任何关系。那为什么 microps 用 TAP 而不用 TUN这才是关键。如果用 TUNTAPTUN收发的数据完整以太网帧含 MAC 头纯 IP 包无 MAC 头microps 能实现 ARP 吗✅ 能自己构造 MAC 头❌ 不能没有 MAC 头可以填microps 能实现完整 TCP/IP 吗✅ 能从二层到四层全自己来⚠️ 缺二层不完整microps 的目的是实现一个完整的 TCP/IP 协议栈用于学习ARP 是 TCP/IP 的一部分。用 TUN 的话ARP 没地方放协议栈就是残的。所以选 TAP 不是为了桥接而是因为TAP 给了 microps 完整的以太网帧让它能自己实现 ARP、自己填 MAC 头从而拥有一个真正完整的协议栈。至于包出去之后怎么路由那是内核的事和 TAP 是几层设备无关。一句话TAP 是二层设备没错但 microps 用 TAP 不是为了做二层桥接而是为了拿到完整以太网帧来实现自己的 ARP。包出了 TAP 之后内核该查路由查路由该走三层走三层——设备是二层的转发路径是三层的不矛盾。2026-6-22 实践记录1. 创建 TAP 设备sudo ip tuntap add dev tap0 mode tap删除命令sudo ip link delete tap02. 配置 IP让宿主机能访问这个虚拟网络sudo ip addr add 10.0.0.1/24 dev tap0 --内核能看到这个地址对microps协议栈而言这是网关IPsudo ip link set dev tap0 up查看tap0的命令 ip a5: tap0: NO-CARRIER,BROADCAST,MULTICAST,UP mtu 1500 qdisc fq_codel state DOWN group default qlen 1000link/ether 62:7f:34:65:fe:85 brd ff:ff:ff:ff:ff:ffinet 10.0.0.1/24 scope global tap0valid_lft forever preferred_lft foreverinet6 fe80::607f:34ff:fe65:fe85/64 scope linkvalid_lft forever preferred_lft forever3. 编写监听程序#includestdio.h#includestring.h#includeunistd.h#includefcntl.h#includesys/ioctl.h#includelinux/if.h#includelinux/if_tun.h#includenetinet/in.h#includearpa/inet.h#includenetinet/ip.hvoidprint_packet(unsignedchar*buf,intn){if(n14){printf([太短 %d字节]\n,n);return;}unsignedchar*src_macbuf;unsignedchar*dst_macbuf6;uint16_tether_type(buf[12]8)|buf[13];printf(---------- 包 len%d ----------\n,n);printf(以太网头:\n);printf( Dst MAC: %02x:%02x:%02x:%02x:%02x:%02x\n,dst_mac[0],dst_mac[1],dst_mac[2],dst_mac[3],dst_mac[4],dst_mac[5]);printf( Src MAC: %02x:%02x:%02x:%02x:%02x:%02x\n,src_mac[0],src_mac[1],src_mac[2],src_mac[3],src_mac[4],src_mac[5]);printf( Type: 0x%04x ,ether_type);switch(ether_type){case0x0800:printf((IPv4)\n);if(n34){structiphdr*ip(structiphdr*)(buf14);structin_addrs,d;s.s_addrip-saddr;d.s_addrip-daddr;printf( IPv4:\n);printf( Src IP: %s\n,inet_ntoa(s));printf( Dst IP: %s\n,inet_ntoa(d));printf( Proto: %d\n,ip-protocol);}break;case0x0806:printf((ARP)\n);break;case0x86dd:printf((IPv6)\n);break;default:printf((未知)\n);break;}}/* * gcc -o read_tap2 read_tap_2.c * */intmain(){intfdopen(/dev/net/tun,O_RDWR);if(fd0){perror(open);return1;}structifreqifr;memset(ifr,0,sizeof(ifr));ifr.ifr_flagsIFF_TAP|IFF_NO_PI;strncpy(ifr.ifr_name,tap0,IFNAMSIZ);if(ioctl(fd,TUNSETIFF,ifr)0){perror(ioctl);return1;}unsignedcharbuf[2048];while(1){intnread(fd,buf,sizeof(buf));if(n0){perror(read);break;}print_packet(buf,n);}close(fd);return0;}4. 运行1、程序运行后会偶尔收到以太网广播报文2、发ping包或udp包程序会显示不断收到arp请求包。–注意不要给10.0.0.1发包要给10.0.0网段的其它地址发包。原理是报文先到内核协议栈协议栈发现是10.0.0网段就会把报文转发给tap0这个网关。这样我们就读取到这个报文了。ping 10.0.0.10echo “hello123456789” |./busybox nc -u 10.0.0.5 99993、我们代码中目前没有响应arp请求所以就卡在arp这一步了。可以手工增加arp表然后就可以收到ping报文和udp报文了。查看arp表有两个命令 arp 或 ip neigh show 删除arp表中一个ip sudo ip neigh del 10.0.0.10 dev tap0 添加一个ipmac地址随便写一个不冲突的 sudo ip neigh add 10.0.0.10 lladdr 00:00:5e:00:53:01 dev tap0 sudo ip neigh add 10.0.0.5 lladdr 00:00:5e:00:53:02 dev tap0协议17代表udp协议协议1代表icmp协议发upd报文时多发一个字符就看到包len增加1--------- 包 len48 ----------以太网头:Dst MAC: 62:7f:34:65:fe:85Src MAC: 00:00:5e:00:53:01Type: 0x0800 (IPv4)IPv4:Src IP: 10.0.0.1Dst IP: 10.0.0.10Proto: 17---------- 包 len50 ----------以太网头:Dst MAC: 62:7f:34:65:fe:85Src MAC: 00:00:5e:00:53:01Type: 0x0800 (IPv4)IPv4:Src IP: 10.0.0.1Dst IP: 10.0.0.10Proto: 17