---
title: "把 TCP 交还给内核：TUN 代理里的 system netstack 设计与实测"
source: https://www.embermoth.blog/2026/09/26/system-netstack-kernel-tcp-for-tun/
date: 2026-09-26
updated: 2026-09-26
tags: [performance, rust, smoltcp, tun, tcp, clash-rs, nat, network-extension, ios, android]
categories: [开发笔记]
---

# 把 TCP 交还给内核：TUN 代理里的 system netstack 设计与实测

> **TL;DR** — 移动端隧道进程的内存墙让用户态 TCP 栈（smoltcp）成了负担：每条连接要背几百 KiB 的固定缓冲，还要自己处理重传、拥塞和窗口。system netstack 换了一条路：不实现 TCP，而是把 TUN 里读到的 TCP 包**原地做源 NAT**，改写后写回 TUN，让内核把它当成发给本机 listener 的连接；accept 出来的普通 socket 再按 NAT 表里恢复的原始五元组交给分发器。代价是每个 TCP 载荷要穿越 TUN 两次，第一版因此比 smoltcp 慢 40%，靠 GRO/GSO 批量 I/O 追回来；收益是每条连接的用户态内存从 1 MiB 降到一个约 100 字节的 NAT 表项。这篇文章记录设计、那些只有真机才会教你的坑，以及实测数字。

* * *

## 一、问题：移动端隧道进程的内存墙

TUN 模式的代理客户端要在用户态解析 IP 包并把 TCP 流转发出去。常见做法是跑一个用户态 TCP/IP 栈，比如 smoltcp、gVisor 的 netstack 或 lwIP。clash-rs 用的是 smoltcp。

桌面上这没什么问题。到了 iOS，情况完全不同：Packet Tunnel 运行在 Network Extension 进程里，系统给这个进程的内存上限只有几十 MB，超了直接被杀，用户看到的就是”VPN 掉线”。Android 的 VpnService 宽松一些，但仍然是后台进程的预算。

在这个预算里跑 smoltcp，有两个结构性问题：

1.  **每条 TCP 连接的固定内存**。clash-rs 原版为每条 TUN 连接分配 2 × 256 KiB 的 smoltcp `SocketBuffer` 加 2 × 256 KiB 的 relay ring buffer，也就是 1 MiB，而且全部零初始化。64 条并发连接就是 64 MiB，一个网页打开就能把 Extension 打爆。我们后来在移动端把它收到每块 64 到 128 KiB，但线性增长的形状不变。
2.  **用户态要自己扛 TCP 的全部行为**。重传、拥塞控制、窗口缩放、SACK、弱网恢复，这些内核已经做了三十年的事，smoltcp 需要在移动端的休眠、切网、弱网条件下重新被验证一遍。

而内核的 TCP 栈就在旁边，免费、成熟、内存不算在我们进程头上。问题只是：TUN 里读出来的是 IP 包，不是 socket。

## 二、思路：不实现 TCP，而是把 TCP 包”骗”给内核

这个思路不是我们发明的，灵感来自 sing-box 的 `stack: system`。核心只有一句话：

> **把每个 TCP 包的源地址改成一个本机可路由的地址，目的地址改成本机一个正在 listen 的端口，再写回 TUN。内核会认为这是一条发给本机的连接，替我们完成整个 TCP。**

具体涉及四个地址角色：

| 角色 | 举例 | 说明 |
| --- | --- | --- |
| 客户端地址 | `10.0.0.9:12345` | App 发包时的源地址 |
| 网关地址 | `198.18.0.1` | TUN 接口自己的地址，也是内核 listener 绑定的地址 |
| NAT 地址 | `198.18.0.2` | 网关 + 1，改写后的源地址，必须在 TUN 前缀内，且不能是网络地址或广播地址 |
| listener 端口 | `40000` | 内核 listener 的端口，启动时随机绑定 |

数据流是这样的：

```text
上行（App -> 远端）

  App 发出  10.0.0.9:12345 -> 1.1.1.1:443
      |
      v  TUN 读出
  改写为    198.18.0.2:P    -> 198.18.0.1:40000      P = NAT 表分配的端口
      |
      v  写回 TUN
  内核路由：目的是本机地址，投递给 listener
      |
      v
  accept() 得到一条普通 TcpStream，peer = 198.18.0.2:P
      |
      v  用 P 反查 NAT 表
  恢复原始五元组 10.0.0.9:12345 -> 1.1.1.1:443，交给分发器 / 出站

下行（内核 listener 的回包）

  内核发出  198.18.0.1:40000 -> 198.18.0.2:P
      |
      v  路由回 TUN，被我们读出
  改写为    1.1.1.1:443      -> 10.0.0.9:12345
      |
      v  写回 TUN
  App 看到的是"服务器直接回了我"
```

为什么 NAT 地址必须是网关 + 1 且在同一前缀内？因为 listener 的回包目的地址是 NAT 地址，内核要把它路由回 TUN 接口，我们才有机会读出来改写。如果 NAT 地址不在 TUN 前缀里，回包会走默认路由，整条链就断了。

UDP 不走这条路。UDP 包原样交给已有的 IP 报文编解码器和 datagram 分发路径，DNS 劫持等逻辑一个字节都不用改。ICMP 只处理一种：发给网关地址的 echo request，原地改成 reply 写回，其余丢弃。

## 三、每个包上发生了什么

### 1）解析：只做必要的校验，拒绝处理不了的形态

IPv4 只看头长、总长和分片标志。分片包直接拒绝：端口只在第一个分片里，要正确处理必须重组，我们不做。

IPv6 要走一遍扩展头链：Hop-by-Hop、Routing、Destination Options 按 8 字节单位跳过，Authentication Header 按 4 字节单位跳过，遇到 Fragment（44）或 ESP（50）拒绝，前者需要重组，后者根本看不到端口。

所有下标访问前都有长度检查。这一层是纯字节操作，没有 I/O，没有共享状态，所以能用最朴素的单元测试覆盖。

### 2）分类：这个包是上行还是下行

判断下行只需要三个条件同时成立：源地址等于网关地址，目的地址等于 NAT 地址，源端口等于 listener 端口。其余全部按上行处理。

```rust
let rewritten = if view.source == tun_address
    && view.destination == nat_address
    && source_port == listener_port
{
    // 下行：用目的端口（NAT 端口）反查原始五元组
    let entry = nat.lookup_back(destination_port)?;
    rewrite_ipv4_tcp(packet, header_len, entry.key.destination, entry.key.source)
} else {
    // 上行：按五元组查或分配 NAT 端口
    let key = TcpKey { source, destination };
    let port = nat.lookup_or_insert_packet(key, is_initial_tcp_syn(packet, tcp))?;
    rewrite_ipv4_tcp(
        packet,
        header_len,
        SocketAddrV4::new(nat_address, port),
        SocketAddrV4::new(tun_address, listener_port),
    )
};
```

下行查不到映射的包直接丢弃，而不是带着一个错误的五元组转发出去。

### 3）改写：RFC 1624 增量校验和

改写四元组要同时更新 IP 头校验和（IPv4）和 TCP 校验和。全量重算 TCP 校验和要扫一遍载荷，1500 字节的包就是 750 次加法。RFC 1624 的增量更新只需要对被替换的字做减旧加新：

```rust
/// 把一次 16 位字替换累加到未折叠的校验和上。
/// 先把所有替换加完再折叠，避免 IPv4 折叠 6 次、IPv6 折叠 18 次。
#[inline]
fn checksum_add_replacement(sum: &mut u32, old: u16, new: u16) {
    *sum += (!old as u32) + new as u32;
}

#[inline]
fn checksum_finish_replacements(sum: u32) -> u16 {
    !checksum_fold(sum)
}
```

IPv4 要替换 4 个地址字加 2 个端口字，IPv6 要替换 16 个地址字加 2 个端口字（地址参与 TCP 伪首部）。成本与载荷大小无关。

一个容易漏的细节：TCP 校验和算出来是 0 时要写成 `0xFFFF`，因为 0 在 UDP 里表示”无校验和”，部分实现对 TCP 也会拒绝。

改写在接收 buffer 上**原地**完成。同一块 buffer 改完直接写回 TUN，TCP 路径上没有一次载荷拷贝，也没有一次 channel 传递。

### 4）处置：三种结果

处理函数对每个包返回一个处置：`WriteBack(len)` 原地改写完成，把这么多字节写回设备；`ForwardUdp` 把整个 IP 包移交给 UDP 编解码器；`Done` 丢弃。I/O 驱动只需要根据处置动作，不需要知道协议细节，这让 Linux 批量、macOS 批量、移动端逐包三套驱动共享同一段处理逻辑。

## 四、NAT 表：把状态机写清楚比写快更重要

NAT 表是这个设计里唯一的共享可变状态，所有难写的 bug 都在这里。

### 1）结构

双向映射：正向表按五元组查 NAT 端口，反向表按 NAT 端口查五元组。两张表都分 64 片，按哈希取片，让并发流不挤同一把锁。锁序固定为**正向先于反向**，`lookup_or_insert` 持有正向锁时取反向锁，任何需要两把锁的地方要么按这个顺序，要么先释放一把。

### 2）三个状态

```rust
enum TcpEntryState {
    HalfOpen,                   // 已分配端口，内核 listener 尚未 accept
    Active,                     // 已 accept，流正在分发器里
    Closing { since: Instant }, // 流已结束，等内核的 FIN/ACK 翻译完
}
```

为什么需要 `Closing`？当分发器里的 TcpStream 被 drop，内核才开始关闭这条 accept 出来的 socket，它发出的 FIN 和最终 ACK 仍然要经过下行改写才能到达 App。如果流一结束就删映射，这些包查不到映射被丢弃，App 只能等自己的超时。所以映射要在 `Closing` 状态多活一个 linger 窗口（10 秒），由周期清理回收。

### 3）generation：防止旧定时器拆掉新连接

同一个五元组的连接可以关掉再重连。如果重连复用了同一个 NAT 端口，旧连接的 linger 清理就可能把新连接的映射删掉；更糟的是，复用刚关闭的端口会撞上内核 socket 的 TIME\_WAIT。

解法是两条：每次 `accept` 给表项打一个递增的 generation，清理和标记操作都带 generation，不匹配就不动；一个不带 ACK 的 SYN 到来时，如果现有映射处于 `Closing`，直接删掉它并分配一个**不同的**端口。

```rust
/// SYN 且无 ACK 才算新连接。SYN 重传仍复用 HalfOpen 映射，
/// 这个信号只用来替换分发器已标记为 Closing 的映射。
#[inline]
fn is_initial_tcp_syn(packet: &[u8], tcp_offset: usize) -> bool {
    packet.get(tcp_offset + 13).is_some_and(|flags| flags & 0x12 == 0x02)
}
```

### 4）清理不能黑洞活连接

周期清理先在各分片里收集过期端口，释放锁，再逐个删除。收集与删除之间，同一条流的新包可能刷新了 `last_seen`，或者 `accept` 已经把它变成 `Active`。删除前必须再检查一次”仍然过期”，否则下行路径会找不到映射，把内核发来的每个包都丢掉，这条活连接就被黑洞了。

### 5）有界

表项有预算，耗尽时返回错误而不是继续分配。分配新端口时探测上限 4096 次：表接近满时如果没有这个上限，一个包就会在持锁状态下扫完 55000 个端口。NAT 表项的预算与”活跃 TCP 会话”的准入预算是分开的：一次浏览器突发会同时产生大量 SYN、半开和 linger 中的映射，早期把两者绑在一起时，iOS 上 256 个五元组就把整张表耗尽了，而每个表项其实只有 100 字节。

### 6）任务结束时的兜底

`Active` 表项只有在会话任务正常返回时才被标记 `Closing`。分发器里一个被 tokio 吞掉的 panic，或者一次 abort，就会留下一个永远不会被回收的 `Active` 表项。修法是一个持有 NAT 引用、端口和 generation 的 guard，`Drop` 里调 `mark_closing`，任务无论怎么结束都会执行。

## 五、accept 侧：内核给我们一条流，我们还它一个五元组

listener 绑在网关地址的 0 端口，backlog 取自平台队列预算。accept 循环做四件事：

1.  **只接受来自 NAT 地址的对端**。listener 对本机其他流量也可达，不是我们改写出来的连接直接丢。
2.  **一个临界区里完成”查五元组 + 标 Active + 打 generation”**。分开做的话，清理可能在两步之间把表项回收。
3.  **准入预算**。拿不到许可时，不是删映射，而是 drop socket 并把映射标为 `Closing`：内核已经完成了握手，drop 会让它发 FIN 或 RST，这个包经过下行改写能到达 App，App 立刻看到连接失败。删映射的效果是相反的，App 会对着黑洞重传到超时。
4.  **错误分类**。`EMFILE`、`ENFILE`、`ENOBUFS`、`ENOMEM` 是资源耗尽，退避 50 ms 再试并限频告警；`EINTR`、`EPROTO`、连接被对端中止是瞬时错误，1 ms 后重试；其他视为致命，整个栈退出并把错误向上报。

accept 出来的 `TcpStream` 用恢复的原始五元组构造会话，走和 smoltcp 栈完全相同的 `dispatch_stream`。分发器、路由、出站对两个栈没有任何区别。

## 六、真机教我们的一课：Darwin 上 listener 必须绑到 utun

这个坑值得单独一节，因为它在 Linux 上完全不存在，而且症状极具误导性。

iOS 真机上的现象：packet rewrite 的计数一直在涨，说明上行改写和写回都在工作；但 TCP accept 的计数始终为零。抓包能看到改写后的 SYN 进了 utun，却没有任何回应。

原因是 Darwin 的路由与 socket 绑定语义。listener 只绑了地址没绑接口，内核不把从 utun 重新注入的 SYN 与这个 listener 关联起来。sing-box 在 Darwin 上有一个 `ForwarderBindInterface` 的设计，做的正是这件事。修法是 bind 之前用 `IP_BOUND_IF` / `IPV6_BOUND_IF` 把 socket 绑到实际的 utun 接口：

```rust
let socket = Socket::new(Domain::IPV4, SocketType::STREAM, Some(Protocol::TCP))?;
#[cfg(any(target_os = "ios", target_os = "macos"))]
if let Some(interface_index) = interface_index {
    // 没有 IP_BOUND_IF，iOS Network Extension 可以成功把 SYN 写回 utun，
    // 内核却永远不会把它交给这个 listener，引擎显示 Running 而 TCP 会话为零。
    socket.bind_device_by_index_v4(Some(interface_index))?;
}
socket.bind(&SockAddr::from(SocketAddrV4::new(address, 0)))?;
socket.listen(backlog)?;
```

接口索引怎么拿？iOS 上 `tun-rs` 故意不暴露设备名，要对复制出来的 raw fd 调 `getsockopt(SYSPROTO_CONTROL, UTUN_OPT_IFNAME)` 读出 `utunN`，再 `if_nametoindex`。

顺着这个坑我们还补了两条启动约束：

-   **就绪信号必须晚于 listener 绑定成功**。之前 TUN 一起来就报 ready，上层看到的是”运行中”，实际上一条 TCP 都进不来。
-   **启动前对照真实接口校验配置的网关地址和掩码**。listener 绑网关地址，NAT 地址从前缀推导，设备实际地址不一致就会绑到一个它没有的地址。Apple 上接口地址有时晚于 fd 出现，所以校验带 10 次、间隔 50 ms 的重试。

IPv6 还有一个小坑：地址可能处于 DAD 的 tentative 状态，`bind` 返回 `EADDRNOTAVAIL`。Linux 用 `IPV6_FREEBIND` 绕过；其他平台回退到绑 `::`，靠 accept 循环的对端过滤兜底。

## 七、性能：第一版比 smoltcp 慢，原因是设备操作翻倍

### 1）问题

system 栈有一个结构性成本：**每个 TCP 载荷要穿越 TUN 两次**。上行被改写后重新注入，listener 的回包又经 TUN 读出、改写、再注入。smoltcp 栈从不重注入上行，它的用户态 TCP 直接消费。同样的载荷，system 栈的设备操作大约是两倍。

在 Linux 上用 iperf3 测第一版，一个 syscall 一个包，结果是 1、16、64 并发流分别比 smoltcp 慢 6.6%、40.5%、43.6%。原因不是 CPU，是设备队列在并发下被打满，丢包换来 TCP 重传。

### 2）批量 I/O

修法是 `tun-rs` 的 offload API：`recv_multiple` 一次 GRO 读（最多 64 KiB）拆成多个包，`send_multiple` 把多个包合并成更少更大的写。两个细节：

-   **`writev` 在这里没用**。TUN 是字符设备，一次向量写仍然只组装一个包，只有 GRO/GSO 能摊薄 syscall。
-   **接收 buffer 的数量要按 MSS 下限算，不是按 MTU**。`recv_multiple` 按流的 MSS 切分一次 GRO 读，MSS 可能远小于 MTU（低 MTU 路径、`advmss` 路由、`TCP_MAXSEG`）。按 MTU 算的数量在 MSS 低于 512 时会溢出报 `ErrTooManySegments`；按 48 字节的 `TCP_MIN_SND_MSS` 算，64 KiB 需要 1367 个 buffer。同时把这个错误当成可丢弃的单次读错误而不是致命错误，对抗性的小分段最多导致丢包，不会拆掉整个后端。

我们还实现并测了一个 ping-pong 方案：读写分在两个任务，来回交换 buffer 组，读永远不被写阻塞。结果更差：三档并发分别 +7.6%、−19.0%、−32.0%。每批一次 channel 往返的调度开销，比它省下的写阻塞更贵。代码里留了注释，免得下一个人再推导一遍。

### 3）三套驱动，一份处理逻辑

| 平台 | 驱动 | 说明 |
| --- | --- | --- |
| Linux | GRO/GSO 批量 | `recv_multiple` / `send_multiple`，TCP 复用接收 buffer 写回，UDP 因批次复用接收区做一次受预算约束的拷贝 |
| macOS | `recvmsg_x` / `sendmsg_x` | 每条消息仍是一个 IP 包，pool buffer 原地改写零拷贝，批量摊薄 syscall 与唤醒 |
| iOS / Android / Windows | 逐包 | 设备每次只给一个包；一次就绪边沿内同步排空最多 32 个包，写用 `try_send` 快路径，满了再走异步等待 |

三套驱动都只有一个任务同时持有设备的读写：读出来的 buffer 改写后直接写回，没有 channel、没有拷贝。UDP 在移动端直接移交 buffer 所有权给编解码器，只有 Linux GRO 路径因为要复用接收区才拷一次。

### 4）数字

Linux 6.18（WSL2），i7-12650H，release 构建，两个 netns 用 veth 相连，iperf3，双方 TUN `txqueuelen` 都设为 10000，取稳态每秒区间的中位数。不经 TUN 的 veth 直连基线约 66 Gbps。

| 并发流 | smoltcp | system | 差值 | smoltcp RSS | system RSS |
| --- | --- | --- | --- | --- | --- |
| 1 | 3.39 Gbps | 4.82 Gbps | +42.2% | 18 MB | 18 MB |
| 16 | 4.36 Gbps | 4.68 Gbps | +7.3% | 47 MB | 18 MB |
| 64 | 4.13 Gbps | 4.09 Gbps | −0.8% | 137 MB | 19 MB |

吞吐上两者在高并发时打平，单流 system 更快。真正的差别在内存，而且是结构性的：system 栈不为连接分配任何用户态缓冲，一条连接的成本就是一个约 100 字节的 NAT 表项；smoltcp 的 137 MB 会随连接数继续线性增长。

一个还没完全解决的毛边：64 流时 system 栈仍记录约 7.4 万次设备 TX 丢包，尽管吞吐已经与 smoltcp 持平。把 `txqueuelen` 继续调大丢包归零、system 略微领先，所以这是突发吸收能力的问题，不是吞吐上限。顺带一提，默认的 `txqueuelen` 500 对多 Gbps 来说两个栈都太小，smoltcp 在 16/64 流时也丢 3.9 万 / 6.7 万包。

## 八、有界：移动端每个队列都要有上限

在 Extension 进程里，任何无界的东西迟早变成一次崩溃。system 栈里每一个可能增长的量都有预算，而且预算来自同一个进程级的 buffer 系统，与 smoltcp 栈共享：

-   NAT 表项数、TCP 准入许可数、UDP 上行与下行队列深度、listener backlog，全部取自平台级的队列上限。
-   iOS 的 TCP 准入在正常、升高、危急三档内存压力下分别是 64、32、16。压力升高**只阻止新会话**，已有会话保留许可自然结束，不为回收内存强拆连接。
-   耗尽时丢弃或拒绝并计数，告警限频到 5 秒一次，绝不阻塞 TCP 路径。
-   入向统计按 64 个包或 100 ms 批量提交一次原子操作，空闲隧道不再每 100 ms 醒一次刷零计数。

## 九、代价与边界

这个方案不是零成本，选它之前要知道它的账单：

-   **设备操作翻倍**。CPU 与功耗在移动端还需要真机测量，Linux 上的吞吐数字不能直接推到 iOS。
-   **内核 socket 内存看不见**。它不在 Rust 堆里，是否计入 Extension 进程的 `phys_footprint` 不能靠堆统计推断，发布前必须测实际峰值。
-   **不重组分片**，IPv4 和 IPv6 都是。带扩展头的 IPv6 UDP 也会被丢弃，因为共享的 UDP 编解码器假定固定 40 字节头。
-   **ICMP 只回应网关**。ping 隧道内任意远端不会有回应。
-   **不在运行中切换栈**。启动失败返回稳定错误，不在半启动状态静默换数据面。
-   **桌面继续用 smoltcp**。桌面没有 Extension 那样的内存墙，没必要为省内存多付一倍设备操作。两个栈共享 buffer 系统、UDP 路径、分发器和出站，切换只是一个配置字段。

## 十、测试怎么写

这个模块最值钱的测试都不需要特权 TUN：

-   **增量校验和对照全量重算**，IPv4 和 IPv6 各一份。IPv6 的测试要从一个正确的校验和出发，因为对零校验和做增量更新结果看起来合理但其实是错的。
-   **下行恢复**：一个从 listener 回到 NAT 地址的包，改写后源目的必须是原始服务器和客户端；**没有映射的下行包必须被丢弃**。
-   **NAT 竞态**：清理已判定过期、删除前同一流的新包到达，映射必须存活；新 SYN 必须拿到与 Closing 映射不同的端口，旧 linger 定时器不能删掉重连。
-   **容量释放**：预算为 1 时第二条流被拒并计数，清理后能再分配。
-   **Darwin 绑定**：在 macOS 上把 listener 绑到 `lo0`，用 `getsockopt(IP_BOUND_IF)` 读回接口索引断言相等。
-   **准入与内存压力**：进入压力不撤销已有许可，只有活跃数降到新上限以下才放行新会话。
-   **Criterion 基准**：纯改写、稳定流 NAT 改写、1024 条新流批量三个场景，直接调用生产的解析、分片查表和改写函数，避免基准演化成第二套算法。

* * *

## 结语

system netstack 的全部想法可以压成一句话：

> **不要在内核旁边再写一个 TCP。把包改写成内核愿意接的样子，让它替你干活，你只负责记住每条连接原来是谁。**

这条路上真正难的不是改写本身，那只是几十行字节操作。难的是三件事：把 NAT 表的状态机和它的竞态写清楚；接受”设备操作翻倍”这个结构性成本并用批量 I/O 把它摊掉；以及承认有些坑只有真机才会教你，比如 Darwin 上那个 SYN 进得去、accept 永远为零的下午。

如果你也在移动端做 TUN 代理，被 Extension 的内存上限追着跑，这个方案值得试。但请先测，再下结论：Linux 上的数字是我们的，你的平台上的数字得是你的。

* * *

## 参考

-   clash-rs 上的实现与讨论：feat(tun): add system (kernel) TCP stack option #1491
-   sing-box 的 `stack: system`，本设计的思路来源，以及 Darwin 上的 `ForwarderBindInterface`
-   RFC 1624：Computation of the Internet Checksum via Incremental Update
-   tun-rs：Linux GRO/GSO 批量收发 API
