---
title: "用 smoltcp 做 TUN 代理的用户态栈：我们踩过的六个坑"
source: https://www.embermoth.blog/2026/09/26/smoltcp-userspace-stack-six-pitfalls/
date: 2026-09-26
updated: 2026-09-26
tags: [performance, rust, smoltcp, tun, tcp, netstack, tokio, concurrency, clash-rs]
categories: [开发笔记]
---

# 用 smoltcp 做 TUN 代理的用户态栈：我们踩过的六个坑

> **TL;DR** — 上一篇讲了移动端为什么把 TCP 交还给内核。桌面端我们仍然用 smoltcp 跑用户态 TCP 栈，因为桌面没有 Extension 那样的内存墙，也不值得为省内存让每个载荷多穿一次 TUN。这条路径的骨架来自 clash-rs 的 netstack，社区已经替我们修过一批坑；这篇记录的是在此之上我们自己踩到的六个：一个 `Listen` 状态的 socket 在 SYN 送达前就被回收、一个形式上是 UB 的 SPSC 环、一个在 tx 队列满时自旋的 poll 循环、一个能终止整条隧道的 UDP 分片包、一个藏在 TxToken 里的 panic，以及每次读都清零 64 KiB 的接收路径。每个坑都给出症状、根因、修法和守住它的测试。

* * *

## 一、这套栈长什么样

先把结构摆出来，后面每个坑都要对着它讲。

```text
TUN 读出 IP 包
    |
    v
NetstackSink        按协议分流：TCP/ICMP 走 smoltcp，UDP 走独立的报文编解码器
    |  (bounded mpsc)
    v
poll_packets 任务    解析 TCP 头；SYN 去重；为新连接创建 smoltcp socket 与 TcpStreamHandle；
    |                把原始帧注入 NetstackDevice 的 rx 队列
    |  (bounded mpsc)
    v
poll_sockets 任务    事件驱动地调 iface.poll()；在 smoltcp socket 与 SPSC 环之间搬字节；
    |                处理半关闭、drop 后的 FIN、socket 回收
    |  (SPSC byte ring x2 / 连接)
    v
TcpStream           面向分发器的 AsyncRead + AsyncWrite，用 AtomicWaker 与 poll_sockets 握手
    |
    v
dispatcher -> 出站
```

smoltcp 自己看到的”网卡”是 `NetstackDevice`：一个实现了 `phy::Device` 的对象，rx 是一条有界 channel，tx 是另一条通向 TUN 写任务的有界 channel。每条连接持有四块固定内存：两块 smoltcp 的 `SocketBuffer`，两块我们自己的 relay 环。所有这些内存都在一个进程级的预算下，连接建立前先拿 permit，拿不到就拒绝这个 SYN。

## 二、社区已经替我们踩过的坑

在讲我们自己的坑之前，先列出 clash-rs 的 netstack 里已经修好、我们读代码时才明白为什么要这样写的几处。它们不是我们的功劳，但理解它们是理解后面六个坑的前提。

| 坑 | 修法 |
| --- | --- |
| `Device::receive` 先从 rx 队列取包、再预留 tx 槽，tx 满时取出的 ACK 被静默丢掉，smoltcp 发送窗口永不前进，下载停在 0 | 先 `try_reserve` tx 槽，拿不到就不碰 rx 队列 |
| 握手期 `may_recv()` / `may_send()` 返回 false，被当成对端关闭，读写标志是单向的，一旦置位连接永久坏掉 | 只在状态离开 `Listen` / `SynSent` / `SynReceived` 之后才据此置位 EOF 与 BrokenPipe |
| 应用侧 `TcpStream` 一 drop 就移除 socket，smoltcp 发送环里排队的数据被丢，大传输尾部截断 | 先把 relay 发送环排空进 smoltcp，再 `close()` 发 FIN，等 `is_active()` 变 false 才移除 |
| 同一 SYN 的重传各自创建一个 socket | 五元组 SYN tracker，60 秒 TTL，上限一万条 |
| `poll_read` 先查空再注册 waker，中间被填充则 wake 丢失 | 先注册 waker 再复查，两处都是 |

这些修复对应上游的几个 PR（`#1069`、`#1070`、`#1075`、`#1322`），感谢 clash-rs 的贡献者们。

## 三、坑一：`Listen` 状态的 socket 在 SYN 送达前就被回收了

### 症状

桌面端饱和下载期间，新建连接全部 `ECONNREFUSED`，持续大约一分钟；偶尔还会出现新连接被归到错误来源的现象。下载本身不受影响，所以很容易被当成远端问题。

### 根因

这是一条四步的链：

1.  `poll_packets` 为新 SYN 创建 smoltcp socket 并调用 `listen(dst)`，**先**通过控制总线把 socket 交给 `poll_sockets` 注册，**再**把原始 SYN 帧注入设备 rx 队列。顺序是对的，否则 SYN 到达时没有 listener 会直接被 RST。
2.  但 `NetstackDevice::receive` 在 tx channel 满时会返回 `None`，把 rx 队列里的包留在原地。饱和下载时 TUN 写端落后，tx 就是满的。于是这个 SYN 会在 rx 队列里停留好几轮 poll。
3.  `poll_sockets` 每轮扫描所有 socket，用 `!socket.is_active()` 判断能否回收。smoltcp 0.14 对 `State::Listen` 的 `is_active()` 返回 **false**。一个还没消费到 SYN 的新 socket，在它眼里和一条已经关闭的连接没有区别。
4.  socket 被移除。SYN 终于被处理时找不到 listener，smoltcp 回 RST。更糟的是 SYN tracker 仍然把这个五元组的重传归到”已存在的连接”，直接注入设备而不再创建 socket，于是接下来 60 秒内这条连接的每一次重试都吃 RST。

### 修法

回收判定要区分状态。`Listen` 状态的 socket 只有在 SYN tracker 也会忘掉它之后才允许过期，其他状态沿用 `is_active()`：

```rust
/// smoltcp 把 Listen socket 报告为不活跃。socket 在 SYN 注入之前就已注册，
/// 而设备会在 tx 满时把 SYN 留在队列里，所以 Listen 可以合法地跨越多轮 poll。
fn retire_tcp_socket(state: tcp::State, is_active: bool, age: Duration) -> bool {
    if state == tcp::State::Listen {
        age >= SYN_TRACK_TTL
    } else {
        !is_active
    }
}
```

`TcpStreamHandle` 因此多记一个 `created_at`。测试直接对这个纯函数写：一秒钟大的 `Listen` 不回收，超过 TTL 的 `Listen` 回收，`Closed` 立刻回收，`Established` 且活跃不回收。

### 教训

第三方库对”不活跃”的定义未必是你的定义。`is_active()` 的文档没有错，错的是我们把”没有活动”读成了”可以扔掉”。

## 四、坑二：一个形式上是 UB 的 SPSC 环

### 症状

没有症状。测试全绿，双线程一百万字节来回搬运也一致。它是在一次静态审计里被指出来的。

### 根因

relay 环原来的形态是这样的：

```rust
pub struct LockFreeRingBuffer {
    buffer: UnsafeCell<Box<[u8]>>,
    // write_pos / read_pos 原子
}
```

生产者写入时 `let buffer = &mut *self.buffer.get();`，消费者读取时 `let buffer = &*self.buffer.get();`。两个线程同时持有对**整个** `Box<[u8]>` 的引用，其中一个还是 `&mut`。虽然两侧实际触碰的字节区间永不重叠，但在 Rust 的别名模型下，`&mut T` 与 `&T` 对同一块内存同时存在就是未定义行为，编译器有权基于”独占”做优化。今天它恰好没做，不代表下个版本不做。

### 修法

把存储改成 `Box<[UnsafeCell<u8>]>`，两侧只持有共享引用，拷贝通过裸指针进入各自的不相交区间：

```rust
pub struct SpscByteRing {
    buffer: Box<[UnsafeCell<u8>]>,
    capacity: usize,
    write_pos: AtomicUsize, // 只有生产者写
    read_pos: AtomicUsize,  // 只有消费者写
}

/// # Safety
/// 只有生产者可调用，且 data.len() 不超过从 pos 起的空闲区。
unsafe fn write_at(&self, pos: usize, data: &[u8]) {
    let first = data.len().min(self.capacity - pos);
    unsafe {
        ptr::copy_nonoverlapping(data.as_ptr(), self.base().add(pos), first);
        ptr::copy_nonoverlapping(data.as_ptr().add(first), self.base(), data.len() - first);
    }
}
```

`UnsafeCell<u8>` 是 `repr(transparent)`，所以第 `i` 个字节就在 `base.add(i)`。位置发布仍然是 `write_pos` 用 `Release` 存、消费者用 `Acquire` 读，反向同理。`unsafe impl Send/Sync` 保留，注释写明前提：每个环恰好一个生产者一个消费者，且类型不对外暴露。

### 教训

“跑得对”和”定义良好”是两回事。本机没有 Miri 不是不改的理由，恰恰是要改的理由，因为你没有工具替你发现它什么时候开始跑不对。

## 五、坑三：tx 队列满时 poll 循环在自旋

### 症状

TUN 写端落后时，运行 `poll_sockets` 的 worker CPU 打满，但吞吐没有任何提升。

### 根因

`poll_sockets` 的节拍由 `iface.poll_delay()` 决定：返回零表示”立刻再 poll”。上游已经在每轮之后加了 `yield_now()`，防止这个循环饿死同一 worker 上的 `NetstackStream` 消费者。但当 tx channel 满时，情况变成：

-   设备的 `receive()` 和 `transmit()` 都拿不到 tx 槽，全部返回 `None`；
-   smoltcp 有段要发，所以 `poll_delay()` 继续返回零；
-   每一轮都是完整的 `iface.poll()` 加 O(sockets) 扫描，什么都做不了，然后 `yield_now()`，然后再来一轮。

`yield_now` 解决的是公平性，解决不了”没有任何事件能让下一轮有进展”。

### 修法

让设备记住自己是因为 tx 满而放弃的，poll 循环据此改变等待方式：

```rust
// NetstackDevice
let Ok(permit) = self.tx_sender.try_reserve() else {
    self.tx_stalled = true;
    return None;
};

// poll_sockets 每轮结束
if device.take_tx_stalled() {
    // 等 TUN 写任务腾出至少一个槽位，上界 20 ms 防止漏唤醒卡死
    let _ = time::timeout(TX_STALL_WAIT, device.wait_for_tx_capacity()).await;
} else {
    task::yield_now().await;
}
```

`wait_for_tx_capacity` 就是 `tx_sender.reserve().await` 然后立刻丢掉 permit：我们只想知道”有位置了”，不想占它。

### 教训

自旋常常伪装成”事件驱动”。判断标准很简单：这一轮 poll 有没有任何可能产生进展。没有，就该等一个具体的条件，而不是让出时间片后立刻回来。

## 六、坑四：一个 UDP 分片包终止整条隧道

### 症状

隧道运行中突然整体断开，日志里最后一条是 “smoltcp UDP dispatcher stopped unexpectedly”。复现条件：任何一个大于 MTU 的 UDP 报文，比如某些 DNS 大应答或 QUIC 探测。

### 根因

UDP 读端的 `SplitRead::recv` 原来是这样的语义：从 channel 取一个 IP 包，解析 IP 头和 UDP 头，任一失败返回 `None`。而调用方把 `None` 理解为”channel 已关闭”，于是退出循环，UDP 分发 future 以错误结束，外层 `select!` 把整个 TUN 后端一起拆掉。

一个大于 MTU 的 UDP 报文会被分片。第一个分片带着完整的 UDP 头，其中长度字段声明的是**整个报文**的长度，远大于这个分片的载荷，`UdpPacket::new_checked` 因此报错。一个包，一次解析失败，一条隧道。

### 修法

两层。读端只在 channel 真正关闭时返回 `None`，解析失败就跳过继续等下一个：

```rust
pub async fn recv(&mut self) -> Option<UdpPacket> {
    loop {
        let data = self.recv.recv().await?;
        if let Some(packet) = parse_udp_packet(data) {
            return Some(packet);
        }
    }
}
```

入口的 `NetstackSink` 也不再把分片和畸形帧往里送。smoltcp 不做重组，传输层头只在第一个分片里，转发分片只会在下游变成畸形 UDP 帧：

```rust
if let IpPacket::Ipv4(ipv4) = &packet
    && (ipv4.more_frags() || ipv4.frag_offset() != 0)
{
    debug!("tun IPv4 fragment dropped");
    return Ok(());
}
```

同一类问题在写端也有一处：地址族不匹配或载荷超过 65507 字节的 UDP 报文无法封包，原来返回错误同样会结束中继。现在按”UDP 本来就是有损的”处理，丢弃、记日志、继续。

三个测试守着它：畸形帧和分片被丢弃且不报错；连续坏帧之后一个好帧仍能被读出；无法封包的出向报文不让中继失败。

### 教训

在数据面里，”解析失败”和”通道关闭”必须是两个类型。用 `Option` 同时表达两件事，迟早有一个被误读。

## 七、坑五：藏在 TxToken 里的 panic

### 症状

今天没有症状，smoltcp 不会请求超过它被告知的 MTU 的帧。但它是一个 panic 面：一旦触发，panic 发生在 `poll_sockets` 任务里，这个任务一死，栈里**所有** TCP 连接一起死。

### 根因

`TxToken::consume(len, f)` 的契约是：给 smoltcp 一块 `len` 字节的缓冲让它序列化帧。我们的实现从 pool 拿一块 MTU 大小的 buffer，`set_len(len)` 后交给 `f`。原来的写法是 `set_len(len).expect("...")`。

### 修法

token 仍然必须运行序列化闭包，这是 smoltcp 的契约；但帧可以不发：

```rust
let Some(packet_buffer) = buffer.set_len(len) else {
    warn!(len, mtu = buffer.free_len(), "dropping smoltcp frame larger than the device MTU");
    let mut scratch = vec![0u8; len];
    return f(&mut scratch);
};
```

顺着这条，我们给整个 packet engine 开了 `clippy::unwrap_used` 与 `clippy::expect_used` 门禁，CI 以 `-D warnings` 执行。确属不变量的位置用 `#[expect(clippy::expect_used, reason = "...")]` 写明依赖的是什么，其他一律改成 `let ... else` 或 `?`。第一次在 CI 上跑这个门禁，就在本机编不到的 Windows 分支里抓出六处。

### 教训

数据面里的每个 `expect` 都要回答一个问题：它 panic 的时候谁陪葬。答案如果是”所有连接”，那它就不该存在。

## 八、坑六：每次读都清零 64 KiB

### 症状

桌面端下行吞吐上不去，profile 里 `memset` 排在前面。

### 根因

`TcpStream::poll_read` 用 tokio 的 `ReadBuf`。原来的写法是 `buf.initialize_unfilled()`，它会把**整个**未填充区清零后再返回切片。分发器给的读缓冲是 64 KiB，而 relay 环里此刻可能只有一个 MSS 的数据。于是每读 1460 字节，先清零 65536 字节。

### 修法

只初始化本次真正能取出的部分：

```rust
let available = read_buf.len().min(buf.remaining());
let recv_buf = buf.initialize_unfilled_to(available);
let n = read_buf.dequeue_slice(recv_buf);
buf.advance(n);
```

同一轮我们还调整了两组”把 TUN 当成不可信物理链路”的默认值。smoltcp 看到的对端是本机内核，链路是内存里的 TUN，这两件事允许我们：

```rust
// 入向校验和不再校验，出向仍然生成
capabilities.checksum.ipv4 = Checksum::Tx;
capabilities.checksum.tcp  = Checksum::Tx;
capabilities.checksum.udp  = Checksum::Tx;

// 这一段 TCP 终止在本机内核，物理链路的拥塞窗口只会带来慢启动和虚假 RTO
socket.set_congestion_control(tcp::CongestionControl::None);
socket.set_ack_delay(None);      // Interface::poll 在批次边界天然合并 ACK
socket.set_nagle_enabled(false);
socket.set_keep_alive(Some(Duration::from_secs(28)));
```

有一条测试专门断言五种协议的校验和都是”不校验、只生成”，防止将来有人把默认值改回去。

### 教训

用户态栈默认按”网卡对面是互联网”配置，而在 TUN 代理里，对面是同一台机器的内核。每一个默认值都值得重新问一遍：这个开销买的是什么。

## 九、把它们放回同一张图

| 坑 | 层 | 症状类型 | 守住它的测试 |
| --- | --- | --- | --- |
| Listen socket 误回收 | poll\_sockets 回收判定 | 新连接被拒一分钟 | `a_listening_socket_waits_for_its_syn_instead_of_being_retired_as_inactive` |
| SPSC 环别名 | relay 环 | 无症状的 UB | 环绕正确性、双线程一百万字节一致性 |
| tx 满时自旋 | poll 循环节拍 | CPU 打满无进展 | `test_receive_drops_inbound_packet_when_tx_channel_full` 与 tx 停滞路径 |
| 分片包杀隧道 | UDP 读端与入口 Sink | 整条隧道断开 | 三条坏帧、分片、封包失败测试 |
| TxToken panic | 设备 tx 路径 | 全部连接陪葬 | unwrap/expect 门禁 |
| 每次读清零 64 KiB | TcpStream 读路径 | 吞吐上限 | 校验和策略断言 |

六个坑里，两个是逻辑错误，两个是”正确但昂贵”，一个是形式上的 UB，一个是 panic 面。它们分布在这套栈的每一层，从设备抽象到面向应用的流，没有一个集中在”TCP 算法”本身。smoltcp 的 TCP 是对的，出问题的全是我们把它接进异步运行时、有界队列和多任务调度时的胶水。

* * *

## 结语

这六个坑有一个共同点：

> **它们都不是 TCP 的问题，而是把一个同步的、假设自己独占网卡的协议栈，接进一个异步的、处处有界的运行时时，边界上的约定没有说清楚。**

`is_active()` 的语义、`Option` 同时表达两种结束、”立刻再 poll”在没有进展时意味着什么、`&mut` 对整块内存意味着什么、`expect` panic 时谁陪葬、`ReadBuf` 的初始化范围。每一条都是边界处的约定，每一条都在代码里有了对应的测试。

如果你也在用 smoltcp 做 TUN 代理，我的建议是：不要只测 TCP 流量对不对，去测那些边界。tx 满的时候会发生什么，一个畸形包进来会发生什么，应用侧 drop 的时候环里还有数据会发生什么。这些地方才是用户态栈真正会坏的地方。

* * *

## 参考

-   smoltcp 0.14
-   clash-rs 的 netstack 实现与相关修复：`#1069`、`#1070`、`#1075`、`#1322`
-   上一篇：[把 TCP 交还给内核：TUN 代理里的 system netstack 设计与实测](https://www.embermoth.blog/2026/09/26/system-netstack-kernel-tcp-for-tun/)
-   tokio `ReadBuf::initialize_unfilled_to`
-   Rust 参考手册中关于别名与 `UnsafeCell` 的章节
