用 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 | TUN 读出 IP 包 |
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,持续大约一分钟;偶尔还会出现新连接被归到错误来源的现象。下载本身不受影响,所以很容易被当成远端问题。
根因
这是一条四步的链:
poll_packets为新 SYN 创建 smoltcp socket 并调用listen(dst),先通过控制总线把 socket 交给poll_sockets注册,再把原始 SYN 帧注入设备 rx 队列。顺序是对的,否则 SYN 到达时没有 listener 会直接被 RST。- 但
NetstackDevice::receive在 tx channel 满时会返回None,把 rx 队列里的包留在原地。饱和下载时 TUN 写端落后,tx 就是满的。于是这个 SYN 会在 rx 队列里停留好几轮 poll。 poll_sockets每轮扫描所有 socket,用!socket.is_active()判断能否回收。smoltcp 0.14 对State::Listen的is_active()返回 false。一个还没消费到 SYN 的新 socket,在它眼里和一条已经关闭的连接没有区别。- socket 被移除。SYN 终于被处理时找不到 listener,smoltcp 回 RST。更糟的是 SYN tracker 仍然把这个五元组的重传归到”已存在的连接”,直接注入设备而不再创建 socket,于是接下来 60 秒内这条连接的每一次重试都吃 RST。
修法
回收判定要区分状态。Listen 状态的 socket 只有在 SYN tracker 也会忘掉它之后才允许过期,其他状态沿用 is_active():
1 | /// smoltcp 把 Listen socket 报告为不活跃。socket 在 SYN 注入之前就已注册, |
TcpStreamHandle 因此多记一个 created_at。测试直接对这个纯函数写:一秒钟大的 Listen 不回收,超过 TTL 的 Listen 回收,Closed 立刻回收,Established 且活跃不回收。
教训
第三方库对”不活跃”的定义未必是你的定义。is_active() 的文档没有错,错的是我们把”没有活动”读成了”可以扔掉”。
四、坑二:一个形式上是 UB 的 SPSC 环
症状
没有症状。测试全绿,双线程一百万字节来回搬运也一致。它是在一次静态审计里被指出来的。
根因
relay 环原来的形态是这样的:
1 | pub struct LockFreeRingBuffer { |
生产者写入时 let buffer = &mut *self.buffer.get();,消费者读取时 let buffer = &*self.buffer.get();。两个线程同时持有对整个 Box<[u8]> 的引用,其中一个还是 &mut。虽然两侧实际触碰的字节区间永不重叠,但在 Rust 的别名模型下,&mut T 与 &T 对同一块内存同时存在就是未定义行为,编译器有权基于”独占”做优化。今天它恰好没做,不代表下个版本不做。
修法
把存储改成 Box<[UnsafeCell<u8>]>,两侧只持有共享引用,拷贝通过裸指针进入各自的不相交区间:
1 | pub struct SpscByteRing { |
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 | // NetstackDevice |
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 | pub async fn recv(&mut self) -> Option<UdpPacket> { |
入口的 NetstackSink 也不再把分片和畸形帧往里送。smoltcp 不做重组,传输层头只在第一个分片里,转发分片只会在下游变成畸形 UDP 帧:
1 | if let IpPacket::Ipv4(ipv4) = &packet |
同一类问题在写端也有一处:地址族不匹配或载荷超过 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 | let Some(packet_buffer) = buffer.set_len(len) else { |
顺着这条,我们给整个 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 | let available = read_buf.len().min(buf.remaining()); |
同一轮我们还调整了两组”把 TUN 当成不可信物理链路”的默认值。smoltcp 看到的对端是本机内核,链路是内存里的 TUN,这两件事允许我们:
1 | // 入向校验和不再校验,出向仍然生成 |
有一条测试专门断言五种协议的校验和都是”不校验、只生成”,防止将来有人把默认值改回去。
教训
用户态栈默认按”网卡对面是互联网”配置,而在 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 设计与实测
- tokio
ReadBuf::initialize_unfilled_to - Rust 参考手册中关于别名与
UnsafeCell的章节