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

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


一、这套栈长什么样

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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():

1
2
3
4
5
6
7
8
9
/// 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 环原来的形态是这样的:

1
2
3
4
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>]>,两侧只持有共享引用,拷贝通过裸指针进入各自的不相交区间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 循环据此改变等待方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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,解析失败就跳过继续等下一个:

1
2
3
4
5
6
7
8
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 帧:

1
2
3
4
5
6
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 的契约;但帧可以不发:

1
2
3
4
5
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 字节。

修法

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

1
2
3
4
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,这两件事允许我们:

1
2
3
4
5
6
7
8
9
10
// 入向校验和不再校验,出向仍然生成
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 的时候环里还有数据会发生什么。这些地方才是用户态栈真正会坏的地方。


参考