坑边闲话:笔者用了好几年 sing-box / Clash,但真正理解 tun0 的 .1/.2 是什么、内核怎么把包"塞"给用户态进程,其实并不容易。这篇文章试图补上这块认知。

1. 为什么需要 TUN·

1.1 SOCKS / HTTP proxy 的边界·

SOCKS5 / HTTP proxy 是应用层协议。它们要求应用程序主动感知代理的存在,通过特定协议与代理协商连接目标。这带来两个根本限制:

  1. 应用必须支持代理协议。大多数命令行工具(ping、nslookup、某些 CLI)不支持,或者支持方式各异。
  2. 无法拦截不经过 socket API 的流量。虽然理论上大多数流量都走 socket,但你无法在内核层面统一接管所有出站连接。

更关键的是:SOCKS proxy 工作在 L4(TCP/UDP),它看到的是流(stream),不是 IP 包。它没有办法看到完整的 L3 数据包头,也就无法做基于 IP 头的策略路由。

1.2 透明代理需要内核配合·

"透明代理"的语义是:应用程序不需要知道代理的存在,流量自动被拦截和转发。要实现这一点,必须在内核网络栈层面介入。

Linux 的传统方案是 iptables/nftables REDIRECT/TPROXY,可以把 TCP/UDP 流量重定向到本地监听端口。但这依然有局限:只能处理 TCP/UDP,对 ICMP、原始 IP 协议号支持有限;而且 TPROXY 配置复杂。

TUN 的方案更彻底:在 L3 层面创建一个虚拟网卡,让内核把需要代理的流量路由到这张虚拟网卡,然后用户态程序从这张网卡读取原始 IP 数据包。

1.3 两层路由的分工·

这是理解整个系统的关键结论:

1
2
Linux routing   决定「哪些流量交给 sing-box」
sing-box routing 决定「这些流量怎么走」

Linux 内核路由(ip route / ip rule)负责流量分类和导流:把需要代理的流量打上标记,通过策略路由送入 tun0.

sing-box 用户态路由负责决策:这个目标地址应该走 vless?走 direct?走 shadowsocks?它拿到的是原始 IP 包,可以自由决定如何处理。

2. TUN 是什么·

2.1 一个被广泛误解的设备·

常见的误解是把 TUN 理解为一个 pipe 或 socket——一端是内核,另一端是用户态程序,数据从一端流向另一端。这个模型在直觉上没错,但它遗漏了最重要的部分:

TUN 不是简单的 pipe,而是一个 L3 虚拟网卡(net_device)+ 用户态数据通道

2.2 tun0net_device·

tun0 在内核里是一个标准的 struct net_device,和 eth0lo 是同等地位的网络设备。它:

  • 有 IP 地址(可以是 /30 的点对点地址对,也可以是 /24 等)
  • 注册在内核网络子系统中
  • 可以参与 ip routeip rule 策略路由
  • 可以被 iptables/nftables 的 -i tun0 匹配到
1
2
3
4
5
6
$ ip link show tun0
5: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN
link/none
$ ip addr show tun0
5: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500
inet 172.18.0.1 peer 172.18.0.2/32 scope global tun0

注意 link/none——这是 L3 接口的标志。

2.3 没有二层·

TUN 是纯 L3 接口:

  • 没有 MAC 地址
  • 没有 ARP
  • 不参与以太网帧的封装/解封
  • link/none 而不是 link/ether

这与 TAP 设备的本质区别在于:TAP 模拟的是完整的以太网卡(L2),TUN 只模拟 IP 层设备(L3)。

从内核网络栈的角度看,发往 tun0 的流量在 L3 被「发出」,不经过 L2 帧封装,直接进入 TUN driver 的发送队列。

3. tun0 和「对端」的工作机制·

3.1 point-to-point 地址对的含义·

sing-box 启动时会创建 tun0 并配置类似这样的地址:

1
tun0: inet 172.18.0.1 peer 172.18.0.2/32

这是一个 point-to-point(点对点)虚拟链路的标准配置方式:

1
2
3
4
5
6
7
8
+---------------------------+      +-----------------------------+
| Linux kernel | | sing-box userspace |
| | | |
| tun0 (net_device) | ←──→ | TUN fd (file descriptor) |
| 172.18.0.1 | | 172.18.0.2 (peer addr) |
| | | |
+---------------------------+ +-----------------------------+
虚拟 point-to-point 链路(无物理介质)

.1 是内核侧地址,.2 是"对端"地址。这个对端不是网络中真实存在的机器,它只是一个逻辑标签,用于告诉内核"通过这条链路可以到达 .2"。

3.2 为什么路由要写 via 172.18.0.2·

sing-box 创建 tun0 后,会添加类似这样的路由:

1
ip route add default via 172.18.0.2 dev tun0 table 2022

或者:

1
ip route add 0.0.0.0/0 dev tun0

内核在查找 “如何到达 8.8.8.8” 时,命中这条路由:下一跳是 172.18.0.2,出口设备是 tun0

由于 tun0 是 point-to-point 接口,172.18.0.2 就是链路对端,内核不需要 ARP,直接把 IP 包送入 TUN driver 的发送路径。

1
2
3
4
5
6
7
内核路由决策:
目标 8.8.8.8
-> 命中 table 2022: `via 172.18.0.2 dev tun0`
-> 出口: `tun0`
-> 调用 `tun_net_xmit()`
-> 包进入 tun_queue
-> sing-box `read()` 取走

这里 172.18.0.2 本质是一个"路由锚点",让内核知道该把包交给哪个接口。真正的传输靠的是 TUN driver,不是二层可达性。

4. 数据流全过程·

4.1 出站方向:应用 → sing-box·

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
用户应用程序 (curl, browser, ...)
|
| connect() / sendto()

内核 TCP/IP 栈
| 查 socket 的 routing mark 或 fwmark

策略路由 ip rule
| 匹配 fwmark / uid-range / ...
| → 查 table 2022

路由表 table 2022
| 0.0.0.0/0 via 172.18.0.2 dev tun0

TUN driver (tun_net_xmit)
| 把 skb 放入 tun_queue

/dev/net/tun (字符设备)
| 内核 wake_up() 唤醒等待的读者

sing-box read() 返回,得到原始 IP 包
|
| 解析目标地址,查 sing-box 内部路由规则

路由决策: direct / vless / shadowsocks / ...
|
| 如果是代理:

加密封装(TLS / VMess / VLESS / ...)

通过真实网卡(eth0)发出

4.2 入站方向:远端响应 → 应用·

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
远端服务器响应包

sing-box 收到(通过真实网卡 eth0)

解密 / 解封装

sing-box write() 到 /dev/net/tun

TUN driver:tun_chr_write_iter()
| 调用 netif_rx() 注入内核网络栈
| 包被标记为"来自 tun0"

内核 TCP/IP 栈处理(conntrack、NAT、...)

匹配到等待的 socket

应用程序 recv() 返回

4.3 策略路由的角色·

sing-box 通常配置如下策略路由(以 fwmark 方案为例):

1
2
3
4
5
6
7
8
# 所有出站包打 fwmark 0x1
ip rule add fwmark 0x1 lookup 2022

# table 2022 默认路由走 tun0
ip route add default dev tun0 table 2022

# sing-box 自身流量排除(避免环路)
ip rule add fwmark 0xff lookup main

这里的关键设计是避免路由环路:sing-box 自己通过真实网卡发出的加密流量,不能再被拦截进 tun0, 否则会无限循环。通常通过 fwmark 豁免来解决。

5. TUN 如何"通知"用户态·

5.1 没有中断,靠什么唤醒·

常见问题:TUN 是软件虚拟设备,没有硬件中断,没有 DMA. 内核把包放进队列后,sing-box 怎么知道有数据了?

答案是:文件描述符 + wait queue(等待队列)机制

1
2
3
没有硬件中断(IRQ)
没有 DMA
软件 wakeup + wait queue + 文件描述符可读通知

5.2 /dev/net/tun 是字符设备·

TUN 的用户态接口是 /dev/net/tun,一个字符设备(character device)。sing-box 通过以下步骤使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 打开字符设备
int fd = open("/dev/net/tun", O_RDWR);

// 2. 配置设备名和模式(IFF_TUN = L3 模式)
struct ifreq ifr = {
.ifr_flags = IFF_TUN | IFF_NO_PI,
.ifr_name = "tun0",
};
ioctl(fd, TUNSETIFF, &ifr);

// 3. 阻塞等待内核发来的包
uint8_t buf[65536];
ssize_t n = read(fd, buf, sizeof(buf)); // 阻塞在这里
// n > 0 时,buf 里是一个完整的 IP 包

read() 会阻塞,直到 tun_queue 里有数据。

5.3 内核侧:wait queue 机制·

在内核 drivers/net/tun.c 中,当包进入 tun_queue 时:

1
2
3
4
5
6
// 简化版流程(tun_net_xmit 内部)
skb_queue_tail(&tun->queue, skb); // 入队
wake_up_interruptible_poll( // 唤醒等待的 read()
&tun->wq.wait,
EPOLLIN | EPOLLRDNORM
);

wake_up_interruptible_poll() 会唤醒所有在这个 wait queue 上睡眠的进程。sing-box 的 read()epoll_wait() 因此返回。

1
2
3
4
5
6
7
8
9
10
+------------------+      skb_queue_tail()      +-------------+
| tun_net_xmit() | ─────────────────────────→ | tun_queue |
+------------------+ +-------------+
|
wake_up_interruptible_poll()

+---------------------+
| sing-box's |
| epoll/read return |
+---------------------+

5.4 epoll 模型·

实际上 sing-box 使用 epoll(Go runtime 底层用 epoll)而不是简单的阻塞 read:

1
2
3
4
5
6
7
8
9
10
11
sing-box goroutine

epoll_wait(tun_fd, ...) ← 阻塞等待 EPOLLIN 事件
|
| 内核 wake_up() 触发

epoll_wait 返回 EPOLLIN

read(tun_fd, buf, ...) ← 非阻塞读,拿走 IP 包

处理路由逻辑

epoll 的好处是一个线程可以同时等待 tun_fd 和其他 fd (代理连接的 socket),实现高效多路复用。

6. TUN 的双向性·

6.1 kernel to userspace 方向·

1
2
3
4
5
6
7
8
9
10
11
路由命中 tun0

tun_net_xmit(skb) ← net_device 的 ndo_start_xmit 回调

skb_queue_tail(&tun->queue, skb)

wake_up_interruptible_poll()

用户态 read(fd) / epoll 返回

sing-box 拿到原始 IP 包

这个方向:内核「发送」到 tun0 = 用户态「接收」到数据。

6.2 userspace to kernel 方向·

1
2
3
4
5
6
7
8
9
10
11
12
13
sing-box write(fd, ip_pkt, len)

tun_chr_write_iter() ← 字符设备的 write 回调

tun_get_user()

netif_rx(skb) ← 把包注入内核网络栈

内核认为"收到了来自 tun0 的包"

conntrack、NAT、路由查找

匹配 socket,唤醒应用程序

这个方向:用户态"发送" = 内核"从 tun0 接收到了数据"。

6.3 对称性的意义·

1
2
3
4
5
6
7
+----------------------------+           +-------------------------+
| | read() | |
| kernel route -> tun queue | ────────→ | sing-box process() |
| | | |
| netif_rx() ← tun write | ←──────── | sing-box response() |
| | write() | |
+----------------------------+ +-------------------------+

TUN 的双向性使 sing-box 成为一个完整的 L3 网络协议栈代理:它既能拦截出站流量,又能把响应重新注入内核,对应用程序完全透明。

7. 内核实现细节·

7.1 关键数据结构·

核心代码在 drivers/net/tun.c,主要结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct tun_struct {
struct tun_file __rcu *tfiles[MAX_TAP_QUEUES]; // 每个队列对应一个 fd
struct net_device *dev; // 关联的 net_device
// ...
};

struct tun_file {
struct sock sk; // socket 层兼容
struct socket socket;
wait_queue_head_t wq; // wait queue,read() 在这里睡眠
struct tun_struct *tun;
struct sk_buff_head sk.sk_receive_queue; // 实际的 skb 队列
// ...
};

tun_struct 持有 net_device 的引用,以及若干 tun_file(每个 tun_file 对应一个打开 /dev/net/tun 的 fd)。

7.2 net_device 注册·

TUN 通过 alloc_netdev / register_netdev 把自己注册为标准 net_device:

1
2
3
4
// tun_set_iff() 内部(简化)
dev = alloc_netdev(sizeof(struct tun_struct), name, NET_NAME_UNKNOWN, tun_setup);
// tun_setup 设置 ndo_start_xmit = tun_net_xmit 等回调
register_netdevice(dev);

一旦注册,tun0 就对内核路由子系统可见,可以作为路由出口接口。

7.3 tun_net_xmit: 包进入队列·

当内核路由决定把 skb 发往 tun0 时,调用 tun_net_xmit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static netdev_tx_t tun_net_xmit(struct sk_buff *skb, struct net_device *dev)
{
struct tun_struct *tun = netdev_priv(dev);
struct tun_file *tfile;

// 选择队列(多队列情况)
tfile = rcu_dereference(tun->tfiles[txq]);

// 检查队列长度(防止积压)
if (skb_queue_len(&tfile->sk.sk_receive_queue) >= dev->tx_queue_len) {
dev->stats.tx_dropped++;
kfree_skb(skb);
return NETDEV_TX_OK;
}

// 入队
skb_queue_tail(&tfile->sk.sk_receive_queue, skb);

// 唤醒等待的 read()
wake_up_interruptible_poll(&tfile->wq.wait, EPOLLIN | EPOLLRDNORM);
return NETDEV_TX_OK;
}

注意:如果队列满了,包会被直接丢弃(tx_dropped++),不会阻塞内核。

7.4 tun_chr_read_iter: 用户态读取·

sing-box 调用 read(fd) 时,进入 tun_chr_read_iter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static ssize_t tun_chr_read_iter(struct kiocb *iocb, struct iov_iter *to)
{
struct tun_file *tfile = file->private_data;

// 如果队列为空,阻塞等待
if (skb_queue_empty(&tfile->sk.sk_receive_queue)) {
if (file->f_flags & O_NONBLOCK)
return -EAGAIN;
wait_event_interruptible(tfile->wq.wait,
!skb_queue_empty(&tfile->sk.sk_receive_queue));
}

// 从队列取出 skb
skb = skb_dequeue(&tfile->sk.sk_receive_queue);

// 把 skb 数据拷贝到用户态 buffer
tun_put_user(tun, tfile, skb, to);
kfree_skb(skb);
return ret;
}

整个路径:read()wait_event_interruptible() 睡眠 → tun_net_xmit()wake_up()read() 返回,拿到 IP 包。

8. TUN vs 其他机制·

8.1 对比表·

机制 层级 用户态接口 适用场景 限制
SOCKS5 L4 代理协议 应用主动配置 需应用支持
TUN L3 read/write fd 透明代理/VPN 需内核配合路由
TAP L2 read/write fd 虚拟以太网 含以太网帧头
veth L2 net_device 对 容器网络 两端都在内核

8.2 TUN vs SOCKS·

SOCKS 工作在应用层,要求应用主动感知;TUN 工作在 L3,对应用完全透明。

1
2
3
4
5
SOCKS 代理路径:
应用 → SOCKS client → SOCKS server → 目标

TUN 代理路径:
应用(无感知)→ socket → 内核路由 → tun0 → sing-box → 目标

8.3 TUN vs TAP·

TAP 模拟完整以太网网卡(L2),用户态读到的是以太网帧(含 MAC 头);TUN 只有 L3,读到的是原始 IP 包。

WireGuard 在内核态实现,不走 /dev/net/tun;OpenVPN 早期使用 TUN/TAP 字符设备(用户态实现)。

8.4 TUN vs veth·

veth 是内核内部的虚拟以太网对(L2),两端都在内核,主要用于容器/namespace 隔离。veth 没有直接的用户态数据通道;TUN 的核心价值就是为用户态程序提供 L3 数据平面的直接访问

一句话定义

TUN = 用户态可编程的 L3 网络入口

9. 总结:mental model·

9.1 完整架构图·

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
+----------------------------------------------+
| User Space |
| |
| App (curl / browser) |
| | socket API |
| v |
| sing-box |
| +-- routing rules (domain / IP dispatch) |
| +-- vless / vmess / shadowsocks / direct |
| +-- read(tun_fd) <-------> write(tun_fd) |
| ^ | |
+------------|----------------------|----------+
| /dev/net/tun |
+------------|----------------------|----------+
| | Kernel Space v |
| tun_chr_read_iter() tun_chr_write_iter() |
| ^ | |
| tun_queue netif_rx() |
| ^ |
| tun_net_xmit() <-- ndo_start_xmit |
| ^ |
| ip route / ip rule (policy routing) |
| ^ |
| Kernel TCP/IP stack |
| ^ |
| socket (app connection) |
+----------------------------------------------+

9.2 五个核心问题的答案·

为什么 sing-box 要用 TUN?

SOCKS/HTTP proxy 要求应用感知代理,无法透明拦截所有流量。TUN 在 L3 层面接管流量,对应用完全透明,是实现"全局代理"的唯一内核级方案。

tun0 的 .1/.2 是什么?

point-to-point 虚拟链路的两端地址:.1 是内核侧地址,.2 是逻辑对端(sing-box 侧),不是真实机器,只是路由系统的寻址锚点。

数据包如何从内核进入用户态?

路由命中 tun0, 随后 tun_net_xmit() 把 skb 放入 tun_queue → wake_up() 唤醒 sing-box 的 read()/epoll, sing-box 拿到原始 IP 包。

为什么没有中断还能工作?

靠软件 wakeup + wait queue:内核在入队时调用 wake_up_interruptible_poll(),等待的 read()/epoll 进程被唤醒。这是纯软件机制,与硬件中断无关。

TUN 在 Linux 网络栈中的位置?

TUN 是注册在内核中的 L3 net_device,处于路由决策之后、物理网卡之前。它把内核路由子系统与用户态程序打通,是 VPN/代理工具实现"用户态网络栈"的基础设施。