本文是对 Crafting ICMP-bearing IPv4 packets with the help of bitvec 的整理与翻译。
内容结构概览
- 为什么 IPv4 序列化比 ICMP 更麻烦:IPv4 有 4-bit、3-bit、13-bit 这类非整字节字段。
- 引入
bitvec:用 bit-level vector 处理version + IHL、flags + fragment offset这类字段。 - 封装 bit serializer :在
serialize.rs中定义BitOutput、bits()、WriteLastNBits、BitSerialize。 - 用宏支持
ux类型 :为u2、u3、u4、u6、u13批量实现 bit 序列化。 - 实现 IPv4 序列化骨架:写出 version/IHL、DSCP/ECN、length、identification、flags、TTL、protocol、checksum、src/dst、payload。
- 回填 IPv4 total length 和 header checksum:先生成 buffer,再填长度,再只对 header 计算 checksum。
- 修复两个典型错误:length 应该用 big-endian;IPv4 checksum 只覆盖 header,不覆盖整个 packet。
- 真正发送 ICMP Echo Request:把 ICMP 包包进 IPv4,再包进 Ethernet,目标 MAC 使用上一节 ARP 查询出的网关 MAC。
- 修复 Ethernet IPv4 payload 未实现 :补上
EtherType::IPv4的序列化分支。 - 修复 ICMP 奇数长度 checksum:payload 长度可能为奇数,checksum 计算需要隐式补 0。
- 第一次成功收到 Echo Reply :自制协议栈真正 ping 通
8.8.8.8。 - 把
sup迁移到ersatz:把ersatz从 binary 改成 library,提供InterfaceAPI。 - 设计
Interface::open_default、send_ipv4、expect_ipv4:抽象 ARP、发送 IPv4、等待特定包。 - rawsock 生命周期问题 :
DynamicInterface借用了Library,不能简单把二者一起移动进结构体。 - 用
once_cell管理 rawsock library :让 rawsock library 具备'static生命周期。 - 用
Arc管理共享 interface:监听线程和主线程都需要持有同一个网卡接口。 - 用
Arc<Mutex<PendingQueries>>管理监听器:跨线程共享可变等待队列。 - 理解
Send与Sync:闭包、channel、listener 放进后台线程时必须满足线程安全约束。 - 启动后台 packet processor:解析收到的 Ethernet frame,把 IPv4 包交给注册的 listener。
- 用 scoped thread 先完成 ARP 查询 :
open_default()返回前先拿到网关 MAC。 sup功能完成:循环发送 4 次 Echo Request,等待 Echo Reply,打印 bytes、time、TTL。- 修复 1 秒延迟问题:发现 rawsock/Npcap 读取超时为 1000ms,改为 1ms 后 RTT 接近系统 ping。
- 最终成果:约 1600 行 Rust,实现了 Ethernet、ARP、IPv4、ICMP 的关键部分,真正做出了自己的 ping。
前面十三篇已经把很多底层准备工作都完成了。我们从 Windows 的 IcmpSendEcho 开始,先用系统 API 做出一个能工作的 ping,再逐步离开操作系统帮我们做好的封装,开始自己监听网卡、解析 Ethernet frame、解析 IPv4 packet、解析 ICMP packet,最后还能自己构造 ARP Request,找到默认网关的 MAC 地址。
到了这一篇,终于进入最后阶段:真正手工构造一个 ICMP Echo Request,把它包进 IPv4 packet,再包进 Ethernet frame,然后通过 rawsock/Npcap 发到网卡上。换句话说,不再依赖 Windows 的 ICMP API,不再只是解析别人发出的包,而是我们自己的程序从协议字段开始,构造完整网络数据并发出去。
这一篇内容很密。前半部分重点是序列化 IPv4:这比 ICMP 更麻烦,因为 IPv4 头部里有很多非整字节字段,比如 4 bit 的 version、4 bit 的 IHL、3 bit 的 flags、13 bit 的 fragment offset。为了解决这类 bit-level 字段,文章引入 bitvec。中间部分修复 IPv4 length、header checksum、ICMP 奇数长度 checksum 等问题。后半部分则开始把旧的 sup 工具迁移到新的 ersatz 协议栈上,并在这个过程中处理 Rust 生命周期、线程、Arc、Mutex、Send、Sync 等一连串工程问题。
最后,整个系列终于收束:自制 ping 可以发出 Echo Request,并收到 8.8.8.8 的 Echo Reply。
一、IPv4 序列化为什么比 ICMP 更麻烦
上一篇已经完成了 ICMP 的解析和序列化。ICMP Echo Request 的结构相对简单:type、code、checksum、identifier、sequence number、payload。除了 checksum 需要先置零再回填之外,大多数字段都是整字节或整 16-bit 字段,用 cookie-factory 的 be_u8、be_u16 组合起来就可以。
IPv4 就不一样了。IPv4 header 里有很多字段不是整字节对齐的。比如第一个字节由两个 4-bit 字段组成:version 和 IHL。再比如 flags 是 3 bit,fragment offset 是 13 bit,它们合起来占 16 bit。解析时前面已经用过 nom 的 bit-level parser 来处理这些字段;现在要序列化,就需要反过来把这些小 bit 字段重新拼回字节流。
最直接的方式当然是手写位运算。比如 version 和 IHL 可以这样拼成一个字节:
rust
be_u8((u8::from(self.version) << 4) + u8::from(self.ihl))
这确实能得到 0x45。对于常见 IPv4 包来说,version 是 4,IHL 是 5,二者拼起来就是二进制 0100_0101,也就是十六进制 45。但如果整个 IPv4 序列化都靠这种手写位移,很快会变得难读,也容易出错。前面解析时已经尽量避免手写 bit twiddling,序列化时也应该保持同样思路。
这时就引入了 bitvec。
二、用 bitvec 操作 bit 级数据
bitvec 的作用是让我们像操作普通 Vec 一样操作 bit。它可以把一个整数看成 bit slice,也可以构造一个 bit vector,然后把不同长度的 bit 片段拼接起来。
比如 version 是 4,只需要它最后 4 个 bit:
rust
use bitvec::prelude::*;
let mut vec = BitVec::<BigEndian, u8>::new();
let val = 4u8;
vec.extend_from_slice(&val.bits::<BigEndian>()[4..]);
4u8 的完整 8-bit 表示是 00000100。取后 4 位就是 0100。IHL 是 5,后 4 位是 0101。把两个 4-bit 片段拼起来,就得到:
text
01000101
也就是十六进制 45。这个过程正好对应 IPv4 header 的第一个字节。
接下来可以把这套能力封装起来。新建 serialize.rs:
rust
use bitvec::prelude::*;
use cookie_factory as cf;
use std::io;
pub type BitOutput = BitVec<BigEndian, u8>;
pub fn bits<W, F>(f: F) -> impl cf::SerializeFn<W>
where
W: io::Write,
F: Fn(&mut BitOutput),
{
move |mut out: cf::WriteContext<W>| {
let mut bo = BitOutput::new();
f(&mut bo);
io::Write::write(&mut out, bo.as_slice())?;
Ok(out)
}
}
这个 bits() 是一个桥梁。cookie-factory 负责写字节流,而 bitvec 负责临时构造 bit-level 输出。调用方传一个闭包,在闭包里往 BitOutput 写 bit。闭包结束后,bits() 把 BitVec 的底层字节写进最终输出。
这样,IPv4 的某些字段就可以写成:
rust
bits(move |bo| {
self.version.write(bo);
self.ihl.write(bo);
})
代码表达的是"把 version 和 IHL 写成 bit 序列",而不是"先左移 4 位,再加另一个字段"。这更接近协议结构本身。
三、为 ux 小整数实现 bit 序列化
前面解析 IPv4 时使用过 ux crate,比如 u4、u6、u3、u13。这些类型可以表达"这个字段只有多少 bit"。现在需要让这些类型都能写到 BitOutput 中。
因为 BitOutput 只是类型别名,不是我们自己定义的新类型,所以不能直接给它实现外部 trait。按照 Rust 的 orphan rule,要给它扩展方法,需要自己定义一个 trait:
rust
pub trait WriteLastNBits {
fn write_last_n_bits<B: Bits>(&mut self, b: B, num_bits: usize);
}
impl WriteLastNBits for BitOutput {
fn write_last_n_bits<B: Bits>(&mut self, b: B, num_bits: usize) {
let bitslice = b.bits::<BigEndian>();
let start = bitslice.len() - num_bits;
self.extend_from_slice(&bitslice[start..])
}
}
这个方法的含义是:给定一个整数,只取它最后 N 个 bit,追加到当前 bit vector。比如 u4 的值底层可以先转成 u16,再取最后 4 bit。
再定义一个统一的序列化 trait:
rust
pub trait BitSerialize {
fn write(&self, b: &mut BitOutput);
}
然后给 ux 中的小整数类型实现:
rust
use ux::*;
impl BitSerialize for u4 {
fn write(&self, b: &mut BitOutput) {
b.write_last_n_bits(u16::from(*self), 4);
}
}
u4 只是一个例子,IPv4 还需要 u2、u3、u6、u13。手写多个实现很机械,于是可以用宏批量生成:
rust
macro_rules! impl_bit_serialize_for_ux {
($($width: expr),*) => {
$(
paste::item! {
impl BitSerialize for [<u $width>] {
fn write(&self, b: &mut BitOutput) {
b.write_last_n_bits(u16::from(*self), $width);
}
}
}
)*
};
}
impl_bit_serialize_for_ux!(2, 3, 4, 6, 13);
这样,所有 IPv4 需要的小 bit 宽度都能用统一接口写出。
四、实现 IPv4 的序列化骨架
现在可以开始写 ipv4::Packet::serialize_no_checksum()。它先生成一个 checksum 和 length 都暂时为 0 的 IPv4 packet,后面再回填。
IPv4 header 的基本字段顺序是:
text
version + IHL
DSCP + ECN
total length
identification
flags + fragment offset
TTL
protocol
header checksum
source address
destination address
payload
序列化时可以先硬编码一些暂时不关心的字段。比如 version 固定是 4,IHL 固定是 5,也就是没有 IP options;DSCP 和 ECN 先设为 0;flags 和 fragment offset 也先设为 0,不处理分片。这样代码会简单很多:
rust
pub fn serialize_no_checksum<'a, W: io::Write + 'a>(
&'a self,
) -> impl cf::SerializeFn<W> + 'a {
use crate::serialize::{bits, BitSerialize};
use cf::{
bytes::{be_u16, be_u8},
sequence::tuple,
};
tuple((
bits(move |bo| {
let version = u4::new(4);
let ihl = u4::new(5);
let dscp = u6::new(0);
let ecn = u2::new(0);
version.write(bo);
ihl.write(bo);
dscp.write(bo);
ecn.write(bo);
}),
be_u16(0),
be_u16(self.identification),
bits(move |bo| {
let flags = u3::new(0);
let fragment_offset = u13::new(0);
flags.write(bo);
fragment_offset.write(bo);
}),
be_u8(self.ttl),
move |out| self.payload.protocol().serialize()(out),
be_u16(0),
self.src.serialize(),
self.dst.serialize(),
self.payload.serialize(),
))
}
这里的 be_u16(0) 有两处。第一处是 total length,暂时不知道,因为要等整个包序列化完才知道长度。第二处是 header checksum,也必须先置 0,因为 checksum 的计算本身要求 checksum 字段先当成 0。
协议字段写出之后,payload 也被跟在后面。对于当前目标,payload 主要是 ICMP。
五、给 Payload 和 Protocol 补序列化
IPv4 的 protocol 字段应该由 payload 决定。如果 payload 是 ICMP,那么 protocol 就是 1。可以给 Payload 加一个方法:
rust
impl Payload {
pub fn protocol(&self) -> Protocol {
match self {
Self::ICMP(_) => Protocol::ICMP,
_ => unimplemented!(),
}
}
pub fn serialize<'a, W: io::Write + 'a>(
&'a self,
) -> impl cf::SerializeFn<W> + 'a {
move |out| match self {
Self::ICMP(ref icmp) => icmp.serialize()(out),
_ => unimplemented!(),
}
}
}
Protocol 本身也需要能写成一个字节:
rust
impl Protocol {
pub fn serialize<'a, W: io::Write + 'a>(
&'a self,
) -> impl cf::SerializeFn<W> + 'a {
use cf::bytes::be_u8;
be_u8(*self as u8)
}
}
这里的设计思路和前面 Ethernet payload 一样:某些字段不应该由调用方手动填,而应该从 payload 类型中推导。payload 是 ICMP,就写 Protocol::ICMP。这样可以减少不一致状态,比如 payload 是 ICMP,但 protocol 字段却写成 UDP。
六、第一次比较:差在 length 和 checksum
为了验证 IPv4 序列化是否正确,可以像前一篇验证 ICMP 一样:在解析一个真实 IPv4 包后,把解析出的结构重新序列化,再和原始字节比较。
第一次比较会发现,大部分字段已经对了,但 total length 和 header checksum 还是 0。比如原始包里 length 是 00 3c,序列化结果里是 00 00;原始包里 checksum 是 b2 f9,序列化结果里也是 00 00。
这正是预期中的问题。length 和 checksum 都要等完整 buffer 生成后才能回填。
于是实现 Packet::serialize():
rust
pub fn serialize<'a, W: io::Write + 'a>(
&'a self,
) -> impl cf::SerializeFn<W> + 'a {
use cf::{bytes::le_u16, combinator::slice};
move |out| {
let mut buf = cf::gen_simple(
self.serialize_no_checksum(),
Vec::new(),
)?;
let length = buf.len() as u16;
cf::gen_simple(le_u16(length), &mut buf[2..])?;
let checksum = crate::ipv4::checksum(&buf);
cf::gen_simple(le_u16(checksum), &mut buf[10..])?;
slice(buf)(out)
}
}
这段代码看起来合理,但运行后会发现两个字段都错了。length 写成了 3c 00,但应该是 00 3c。checksum 也不匹配。
这两个错误都很典型。
七、第一个错误:length 要用 big-endian
IPv4 的所有网络字段都使用网络字节序,也就是 big-endian。total length 是普通 IPv4 header 字段,当然应该用 be_u16 写出。前面误用了 le_u16,是因为复制了 checksum 回填的代码。
checksum 那里用 little-endian,是前一篇基于当前 checksum 实现和小端机器做出的权衡;但 length 不是 checksum,它必须按协议正常写 big-endian。
所以 length 回填要改成:
rust
cf::gen_simple(be_u16(length), &mut buf[2..])?;
这会把 60 写成 00 3c,而不是 3c 00。
八、第二个错误:IPv4 checksum 只覆盖 header
第二个错误更容易忽略。IPv4 的 checksum 字段叫 header checksum,它只覆盖 IPv4 header,不覆盖整个 IPv4 packet。前面的代码把整个 buffer,包括 ICMP payload 都拿去计算 checksum,这当然会错。
当前实现固定 IHL 为 5,也就是 IPv4 header 长度固定 20 字节。因此 checksum 应该只对前 20 字节计算:
rust
let header_slice = &buf[..5 * 4];
let checksum = crate::ipv4::checksum(header_slice);
cf::gen_simple(le_u16(checksum), &mut buf[10..])?;
这段代码还有一个隐含限制:如果以后支持 IP options,IHL 就不一定是 5,header 长度也不一定是 20。到那时必须用真实 IHL 计算 header length。当前项目暂时不支持 IP options,所以固定 20 字节可以接受。
修复这两个问题后,原始 IPv4 包和重新序列化出的 IPv4 包就能对齐。header 里的 length、checksum、source、destination、payload 都能匹配。到这里,IPv4 的 parse/serialize 也形成了闭环。
九、准备真正发送 Echo Request
现在 ICMP 能序列化,IPv4 能序列化,Ethernet 和 ARP 也能工作。下一步就是把这些层串起来。
先给 ICMP 加一些便捷方法:
rust
impl Echo {
pub fn as_echo_request<P: AsRef<[u8]>>(
self,
payload: P,
) -> Packet {
Packet {
typ: Type::EchoRequest,
checksum: 0,
header: Header::EchoRequest(self),
payload: Blob::new(payload.as_ref()),
}
}
}
impl Packet {
pub fn as_ipv4_payload(self) -> crate::ipv4::Payload {
crate::ipv4::Payload::ICMP(self)
}
}
再给 IPv4 加默认值和转换方法。一个新构造的 IPv4 packet 需要 source、destination、payload、TTL、identification 等字段。很多字段可以有默认值:
rust
impl Default for Packet {
fn default() -> Self {
Self {
length: 0,
identification: rand::random(),
version: u4::new(4),
ihl: u4::new(5),
dscp: u6::new(0),
ecn: u2::new(0),
flags: u3::new(0),
fragment_offset: u13::new(0),
ttl: 128,
protocol: None,
checksum: 0,
src: Addr::zero(),
dst: Addr::zero(),
payload: Payload::Unknown,
}
}
}
payload 转成 packet:
rust
impl Payload {
pub fn as_packet(self, src: Addr, dst: Addr) -> Packet {
Packet {
protocol: Some(self.protocol()),
payload: self,
src,
dst,
..Default::default()
}
}
}
IPv4 packet 再转成 Ethernet payload:
rust
impl Packet {
pub fn as_ethernet_payload(self) -> crate::ethernet::Payload {
crate::ethernet::Payload::IPv4(self)
}
}
这样就可以写出非常连贯的构造链:
rust
icmp::Echo {
identifier: 0xC0DE,
sequence_number: 0xFACE,
}
.as_echo_request("I'm a little teapot".as_bytes())
.as_ipv4_payload()
.as_packet(nic.address, ipv4::Addr([8, 8, 8, 8]))
.as_ethernet_payload()
.as_frame(nic, gateway_mac)
.send(iface);
这段代码的意思非常清楚:构造 ICMP Echo Request,把它变成 IPv4 payload,再变成 IPv4 packet,再变成 Ethernet payload,再包进发往网关 MAC 的 Ethernet frame,最后发送。
十、第一个运行错误:IPv4 Ethernet payload 还没实现
第一次运行时,程序没有成功发包,而是在 ethernet.rs 里崩溃,原因是 Payload::IPv4 的序列化分支还是 unimplemented!()。
之前只实现了 ARP:
rust
match self {
Self::ARP(ref packet) => {
tuple((EtherType::ARP.serialize(), packet.serialize()))(out)
}
Self::IPv4(_) => unimplemented!(),
Self::Unknown => unimplemented!(),
}
现在要补上 IPv4:
rust
match self {
Self::ARP(ref packet) => {
tuple((EtherType::ARP.serialize(), packet.serialize()))(out)
}
Self::IPv4(ref packet) => {
tuple((EtherType::IPv4.serialize(), packet.serialize()))(out)
}
Self::Unknown => unimplemented!(),
}
这里做的事情和 ARP 一样:先写 EtherType,再写 payload。ARP 的 EtherType 是 0x0806,IPv4 的 EtherType 是 0x0800。
十一、第二个运行错误:ICMP 长度可能是奇数
再次运行后,又崩溃了。这次错误来自 checksum:
text
checksum() input size should be a multiple of 2 bytes
前一篇实现 checksum 时,假设输入长度一定是 2 字节的倍数。对于 IPv4 header 来说,这基本成立,因为 IPv4 header 长度以 32-bit words 为单位。但 ICMP packet 不一定是偶数长度。payload 是用户自己指定的,比如 "I'm a little teapot",长度就可能是奇数。
RFC 792 对这种情况有明确规定:如果 ICMP message 总长度是奇数,计算 checksum 时要在末尾补一个 0 字节。注意这个补 0 只用于计算 checksum,不代表真正发送的 ICMP packet 后面要多一个字节。
一种简单办法是:如果 slice 长度是奇数,就复制一份 Vec,末尾 push 一个 0,再递归计算 checksum。但这样为了一个字节就分配整块 buffer,有点浪费。
当前代码运行在 x86 小端机器上,可以利用一个简化技巧。align_to::<u16>() 会把输入分成 head、words、tail。如果有奇数字节,最后那个字节会落在 tail。在小端语义下,把最后一个 u8 直接当作 u16 加进去,相当于把它放在低字节,高字节为 0。对于当前 checksum 写回方式,这能得到需要的结果。
于是 checksum 改成:
rust
pub fn checksum(slice: &[u8]) -> u16 {
let (head, slice, tail) = unsafe {
slice.align_to::<u16>()
};
if !head.is_empty() {
panic!("checksum() input should be 16-bit aligned");
}
fn add(a: u16, b: u16) -> u16 {
let s: u32 = (a as u32) + (b as u32);
if s & 0x1_00_00 > 0 {
(s + 1) as u16
} else {
s as u16
}
}
!add(
slice.iter().fold(0, |x, y| add(x, *y)),
tail.iter().next().map(|&x| x as u16).unwrap_or_default(),
)
}
这不是最通用的跨平台 checksum 实现,但对当前小端 Windows 环境可以工作。更严谨的版本应该完全按网络字节序处理,并显式处理奇数长度输入。
十二、终于收到 Echo Reply
修复 checksum 后,再次运行,程序终于成功:
text
(192.168.1.16) => (8.8.8.8) | EchoRequest
(8.8.8.8) => (192.168.1.16) | EchoReply
这意味着整个链路打通了:
text
ICMP Echo Request
-> IPv4 packet
-> Ethernet frame
-> rawsock/Npcap
-> 网卡
-> 网关
-> Internet
-> 8.8.8.8
而响应也沿着相反方向回来,被程序的 Ethernet parser、IPv4 parser、ICMP parser 解析出来。
这就是整个系列最关键的成果。前面写了那么多解析器、序列化器、Win32 API 绑定、ARP 查询、checksum 逻辑,最终都在这里合起来了。我们不再调用 IcmpSendEcho,而是真的自己构造了 ICMP-bearing IPv4 packet,并把它送上网络。
十三、把 sup 从 Win32 ICMP API 迁移到 ersatz
前面一直有两个项目。sup 是最开始写的 ping 命令行工具,最初依赖 Windows ICMP API。ersatz 是后来为底层协议栈准备的新项目。既然 ersatz 已经能发包了,就应该让 sup 迁移到 ersatz。
理想 API 大概是:
rust
let iface = ersatz::Interface::open_default()?;
let rx = iface.expect_ipv4(|packet| {
if is_the_echo_reply_we_want(packet) {
return Some(packet.clone());
}
None
});
iface.send_ipv4(echo_request, &addr)?;
这里有三个核心能力:
第一,Interface::open_default() 打开默认网卡,并完成必要初始化,比如查询默认网关 MAC。
第二,send_ipv4() 接收一个 IPv4 payload 和目标地址,自动用本机 IP、网关 MAC、Ethernet frame 封装并发送。
第三,expect_ipv4() 注册一个监听器,等待未来收到的某个 IPv4 packet 满足条件。一旦匹配,就通过 channel 把结果发回调用方。
为了让 sup 依赖 ersatz,先在 sup/Cargo.toml 中加入:
toml
ersatz = { path = "../ersatz" }
然后把 ersatz/src/main.rs 改成 ersatz/src/lib.rs,并把各个模块改成公开模块:
rust
pub mod arp;
pub mod blob;
pub mod ethernet;
pub mod icmp;
pub mod ipv4;
pub mod netinfo;
pub mod parse;
pub mod serialize;
这样 ersatz 就从一个可执行程序变成了一个 library,可以被 sup 使用。
十四、设计 Interface
先给 ersatz 设计一个 Interface 类型:
rust
pub struct Interface {
nic: netinfo::NIC,
gateway_mac: ethernet::Addr,
iface: ...,
pending: ...,
}
nic 保存默认网卡信息,包括本机 IP、本机 MAC、网关 IP。gateway_mac 是通过 ARP 查到的网关 MAC。iface 是 rawsock 打开的网卡接口。pending 用于保存等待中的 IPv4 listener。
send_ipv4() 可以先实现:
rust
pub fn send_ipv4(
&self,
payload: ipv4::Payload,
addr: &ipv4::Addr,
) -> Result<(), error::Error> {
payload
.as_packet(self.nic.address, addr.clone())
.as_ethernet_payload()
.as_frame(&self.nic, self.gateway_mac)
.send(self.iface.as_ref())?;
self.iface.as_ref().flush();
Ok(())
}
这里的封装很清楚。调用者只需要说"我要把这个 IPv4 payload 发到这个 IP"。至于源 IP、源 MAC、目标 MAC、Ethernet frame,都由 Interface 内部处理。flush() 是为了确保 rawsock 的发送队列尽快发出数据。
十五、rawsock 的生命周期问题
真正难的是 iface 字段。rawsock 的 open_interface() 返回的 DynamicInterface 生命周期绑定在 rawsock library 上。也就是说,interface 内部持有对 library 的引用。不能在 open_default() 中创建一个局部 lib,再打开 iface,然后只把 iface 返回出去,因为函数返回后 lib 被释放,iface 内部引用就悬空了。
一开始可能会想:那就把 lib 和 iface 都放进同一个结构体里。问题是,这会形成一种自引用结构。iface 引用了 lib,但二者又要一起移动到 Interface 结构体里。Rust 不允许这样做,因为移动结构体时,字段的内存位置会变化,而生命周期并不是"值多久有效",而是"值在当前位置多久有效"。如果 iface 内部指向原来栈上的 lib,而 lib 被移动进结构体后地址变了,iface 的指针就失效了。
这不是 borrow checker 过于保守,而是在阻止真实的悬垂引用。
解决方式是让 rawsock library 变成 'static。用 once_cell::sync::Lazy 建一个全局 library:
rust
use once_cell::sync::Lazy;
static RAWSOCK_LIB: Lazy<Box<dyn rawsock::traits::Library>> =
Lazy::new(|| open_best_library().unwrap());
这样 library 在程序生命周期内都有效,DynamicInterface 可以安全借用它。这个设计不是最完美,因为打开 library 失败会 panic;如果想把错误返回给调用方,需要再调整 API。但当前阶段先接受这个取舍。
十六、为什么还需要 Arc
open_interface() 返回的是 Box<dyn DynamicInterface>,但后面我们需要两个地方同时使用 interface:主线程调用 send_ipv4() 发送包,后台线程调用 loop_infinite_dyn() 接收包。普通 Box 只有一个所有者,不能被多个线程共享。
rawsock 提供了 open_interface_arc(),可以直接返回:
rust
Arc<dyn DynamicInterface<'static>>
Arc 是 atomically reference counted pointer,也就是线程安全引用计数指针。多个线程可以各自持有一份 Arc clone,底层对象只有一份,最后一个引用释放时对象才会被释放。
于是 Interface 可以保存:
rust
iface: Arc<dyn rawsock::traits::DynamicInterface<'static>>
后台线程也可以拿一份 iface.clone()。这样主线程和后台线程都能使用同一个网卡接口,而不会违反所有权规则。
这里也体现出 Box、Rc、Arc 的区别。Box 是唯一所有权;Rc 是单线程引用计数;Arc 是跨线程引用计数。因为我们要跨线程使用,所以必须是 Arc,不能是 Rc。
十七、设计 PendingQueries 和 expect_ipv4
接下来要让用户注册"我期待某个 IPv4 包"的监听器。可以定义:
rust
struct PendingQueries {
ipv4: Vec<Box<dyn Fn(&ipv4::Packet) -> bool + Send + 'static>>,
}
每个 listener 是一个闭包。它接收 &ipv4::Packet,返回 bool。返回 true 表示匹配成功,并希望从监听列表中移除;返回 false 表示继续等待。
但外部 API 更希望写成:
rust
pub fn expect_ipv4<F, T>(&self, f: F) -> mpsc::Receiver<T>
where
F: Fn(&ipv4::Packet) -> Option<T> + Send + 'static,
T: Send + 'static,
用户传入的闭包返回 Option<T>。如果返回 Some(val),就把 val 通过 channel 发回去,并让内部 listener 返回 true 取消订阅。如果返回 None,说明这个包不是想要的,继续等待。
实现大致是:
rust
pub fn expect_ipv4<F, T>(&self, f: F) -> mpsc::Receiver<T>
where
F: Fn(&ipv4::Packet) -> Option<T> + Send + 'static,
T: Send + 'static,
{
let (tx, rx) = mpsc::channel();
let mut pending = self.pending.lock().unwrap();
pending.ipv4.push(Box::new(move |packet| {
match f(packet) {
Some(val) => {
tx.send(val).unwrap_or(());
true
}
None => false,
}
}));
rx
}
这里有几个关键点。
第一,闭包要 Box::new,因为 Vec<Box<dyn Fn...>> 存的是 trait object,不是具体闭包类型。
第二,闭包要 move,因为它必须拥有 f 和 tx。如果只是借用它们,expect_ipv4() 返回后借用对象就失效了。
第三,F 和 T 都要 Send + 'static。listener 会被放进后台线程使用,闭包捕获的东西必须能跨线程发送,并且不能借用短生命周期局部变量。对这个场景来说,这个要求合理:过滤函数通常只捕获目标 IP、identifier、sequence number 这类可复制的小值。
十八、为什么需要 Arc<Mutex<PendingQueries>>
pending 需要被两个线程访问。主线程会通过 expect_ipv4() 添加 listener;后台 packet processor 线程会收到包后遍历 listener,并在匹配成功后移除 listener。也就是说,它既要被多个线程共享,又要被修改。
只用 Arc<PendingQueries> 不够。Arc 只解决共享所有权,不解决内部可变性,也不保证同时修改安全。PendingQueries 里面还有 mpsc::Sender 和 Box<dyn Fn>,它们不是天然 Sync。如果多个线程同时访问内部数据,必须加锁。
正确组合是:
rust
Arc<Mutex<PendingQueries>>
为什么不是 Mutex<Arc<PendingQueries>>?因为我们想让多个线程持有同一个锁,锁保护同一个资源。Arc<Mutex<T>> 的含义是:多个线程共享同一把锁,每次访问内部 T 之前都要 lock()。而 Mutex<Arc<T>> 则更像是"锁住一个引用计数指针",它不能保护所有线程访问的同一份内部数据。
于是 Interface 变成:
rust
pub struct Interface {
nic: netinfo::NIC,
gateway_mac: ethernet::Addr,
iface: Arc<dyn rawsock::traits::DynamicInterface<'static>>,
pending: Arc<Mutex<PendingQueries>>,
}
后台线程处理 IPv4 包时:
rust
let mut pending = pending.lock().unwrap();
pending
.ipv4
.iter()
.position(|f| f(packet))
.map(|i| pending.ipv4.remove(i));
这里用了 Iterator::position() 替代手写 for enumerate 找 index。position() 会找到第一个满足条件的元素下标,并且短路停止。找到后移除对应 listener。这样代码更短,表达也更准确。
十九、后台 packet processor
Interface::open_default() 要启动一个后台线程,不断从 rawsock 接收 packet,解析成 Ethernet frame,如果 payload 是 IPv4,就交给 pending listener。
大致逻辑是:
rust
let pending = Arc::new(Mutex::new(PendingQueries {
ipv4: Vec::new(),
}));
let iface_for_thread = iface.clone();
let pending_for_thread = pending.clone();
std::thread::spawn(move || {
iface_for_thread
.loop_infinite_dyn(&mut |packet| {
let frame = match ethernet::Frame::parse(packet) {
Ok((_, frame)) => frame,
_ => return,
};
if let ethernet::Payload::IPv4(ref packet) = frame.payload {
let mut pending = pending_for_thread.lock().unwrap();
pending
.ipv4
.iter()
.position(|f| f(packet))
.map(|i| pending.ipv4.remove(i));
}
})
.unwrap();
});
这个线程理论上会长期运行,直到程序退出或 interface 被 break loop。它是 expect_ipv4() 的基础。调用方先注册 listener,再发送 Echo Request,然后等待 channel 上的回复。
注意顺序很重要:必须先注册 listener,再发送请求。否则 Echo Reply 可能很快回来,而监听器还没装上,就会错过响应。
二十、别忘了 ARP:open_default 必须先拿到网关 MAC
Interface::open_default() 里一开始把 gateway_mac 暂时设成 00-00-00-00-00-00。这会导致后面发 IPv4 frame 时,Ethernet 目标 MAC 是全 0,路由器自然不会处理。运行 sup 时会超时。
这说明 ARP 不能留给后面。open_default() 返回前就应该完成网关 MAC 查询,否则 Interface 不是一个真正可用的出站接口。
解决方法是:在 open_default() 中先用 scoped thread 临时监听 ARP Reply,发送 ARP Request,等待网关返回 MAC。拿到结果后,再启动长期的 IPv4 packet processor。
伪代码逻辑如下:
rust
let gateway_mac = crossbeam_utils::thread::scope(|s| {
let (tx, rx) = mpsc::channel();
let gateway_ip = nic.gateway;
let poll_iface = iface.clone();
s.spawn(move |_| {
poll_iface.loop_infinite_dyn(&mut |packet| {
let frame = match ethernet::Frame::parse(packet) {
Ok((_remaining, frame)) => frame,
_ => return,
};
let arp = match frame.payload {
ethernet::Payload::ARP(x) => x,
_ => return,
};
if let arp::Operation::Reply = arp.operation {
if arp.sender_ip_addr == gateway_ip {
tx.send(arp.sender_hw_addr).unwrap();
}
}
}).unwrap();
});
arp::Packet::request(&nic, gateway_ip)
.as_ethernet_payload()
.as_broadcast_frame(&nic)
.send(iface.as_ref())
.unwrap();
let ret = rx
.recv_timeout(Duration::from_secs(3))
.map_err(|_| "ARP timeout")
.unwrap();
iface.break_loop();
ret
}).unwrap();
这里仍然用 scoped thread,是因为这段 ARP 查询只发生在 open_default() 内部,线程不应该活过这个作用域。它监听 ARP Reply,收到结果后调用 break_loop() 停掉临时监听。之后 open_default() 才把 gateway_mac 放进 Interface,再启动长期监听线程。
这一步修复了超时问题的根源:发往外网的 Ethernet frame 终于有了正确的目标 MAC。
二十一、sup 最终长什么样
迁移后的 sup 主程序就可以真正像 ping 工具一样工作:
rust
fn main() -> Result<(), Box<dyn Error>> {
let arg = env::args().nth(1).unwrap_or_else(|| {
println!("Usage: sup DEST");
process::exit(1);
});
let dest = arg.parse()?;
let iface = Interface::open_default()?;
let identifier = 0xC0DE;
let data = "O Romeo.";
println!(
"Pinging {:?} with {} bytes of data:",
dest,
data.len()
);
for sequence_number in 0..4 {
let echo_request = ersatz::icmp::Echo {
identifier,
sequence_number,
}
.as_echo_request(data)
.as_ipv4_payload();
let before = Instant::now();
let rx = iface.expect_ipv4(move |packet| {
if let ipv4::Payload::ICMP(ref icmp_packet) = packet.payload {
if let icmp::Header::EchoReply(ref reply) = icmp_packet.header {
if reply.identifier == identifier
&& reply.sequence_number == sequence_number
{
return Some((before.elapsed(), packet.clone()));
}
}
}
None
});
iface.send_ipv4(echo_request, &dest)?;
match rx.recv_timeout(Duration::from_secs(3)) {
Ok((elapsed, packet)) => {
if let ipv4::Payload::ICMP(ref icmp_packet) = packet.payload {
println!(
"Reply from {:?}: bytes={} time={:?} TTL={}",
packet.src,
icmp_packet.payload.0.len(),
elapsed,
packet.ttl,
);
}
}
Err(_) => {
println!("Timed out!");
process::exit(1);
}
}
std::thread::sleep(Duration::from_secs(1));
}
Ok(())
}
这段代码已经很接近我们一开始想要的命令行工具了。它接收目标 IP,打开默认接口,循环发送 4 次 Echo Request,每次 sequence number 递增,等待最多 3 秒,如果收到匹配 identifier 和 sequence number 的 Echo Reply,就打印源地址、payload 长度、耗时和 TTL。
有个细节很有意思:sequence number 必须递增。实验中如果重复发送相同 sequence number,8.8.8.8 可能不会回复第二个请求。这也说明协议字段不只是装饰,它们会影响真实网络设备的行为。
二十二、程序成功了,但 RTT 很奇怪
第一次完整跑通后,输出大概是:
text
Reply from 8.8.8.8: bytes=8 time=1.000651s TTL=54
Reply from 8.8.8.8: bytes=8 time=301.8831ms TTL=54
Reply from 8.8.8.8: bytes=8 time=251.4266ms TTL=54
Reply from 8.8.8.8: bytes=8 time=210.9397ms TTL=54
能 ping 通已经很好,但这个耗时明显不对。同一台机器上 Windows 自带 ping 8.8.8.8 可能只有 7ms 到 8ms,而自制工具动不动几百毫秒,第一条还接近 1 秒。这个延迟不像真实网络 RTT,更像某个地方有 1 秒缓冲或超时。
发送路径已经调用了 flush(),所以问题不太像发送队列。于是怀疑接收路径。rawsock 在 Windows 上用的是 Npcap/WinPcap 兼容接口。查看 rawsock 代码会发现,它调用 pcap_open_live 时传了一个 read timeout,值是 1000ms:
rust
pcap_open_live(
name.as_ptr(),
65536,
8,
1000,
errbuf.buffer(),
)
这正好解释了接近 1 秒的异常延迟。抓包库可能在接收端缓冲数据,直到超时或条件满足后才把包交给程序。
为了验证这个判断,可以本地 clone rawsock,把 read timeout 从 1000ms 改成 1ms,再让项目依赖本地 path 版本:
toml
rawsock = { version = "0.3.0", path = "./vendor/rawsock" }
改完后再跑:
text
Reply from 8.8.8.8: bytes=8 time=9.8372ms TTL=54
Reply from 8.8.8.8: bytes=8 time=9.0064ms TTL=54
Reply from 8.8.8.8: bytes=8 time=9.9738ms TTL=54
Reply from 8.8.8.8: bytes=8 time=8.9758ms TTL=54
这就和系统 ping 接近了。说明协议栈本身没有慢到离谱,之前的问题主要是抓包库接收 timeout。
二十三、这一篇真正完成了什么
这一篇把整个系列的所有积累最终连在一起。
bitvec 解决了 IPv4 非整字节字段的序列化问题。cookie-factory 继续承担二进制序列化组合器的角色。serialize_no_checksum() 先生成占位包,再回填 total length 和 header checksum。通过比较原始抓包和重新序列化结果,确认 IPv4 序列化正确。
ICMP 奇数长度 checksum 的问题被修复。之前 checksum 函数假设输入长度是偶数,这对 IPv4 header 可以,但对 ICMP 不成立。根据 RFC 792,奇数长度要在 checksum 计算时补 0。修复后,任意长度 payload 的 Echo Request 都能生成正确 checksum。
ARP 查询结果被真正用于发送 IPv4。上一节已经能通过 ARP 找到网关 MAC,这一篇把它放进 Interface::open_default(),确保接口一打开就具备本机 IP、本机 MAC、网关 IP、网关 MAC、rawsock interface 和后台 packet processor。
sup 从 Win32 ICMP API 迁移到 ersatz。旧工具不再调用 IcmpSendEcho,而是通过自研协议栈构造 ICMP/IPv4/Ethernet,注册 listener 等待 Echo Reply,最终打印类似系统 ping 的输出。
Rust 工程层面,这一篇处理了生命周期、自引用结构、once_cell、Arc、Mutex、Send、Sync、trait object、channel、后台线程、scoped thread 等问题。它不只是网络协议文章,也是一次非常真实的 Rust 系统工程实践。
二十四、这一篇的 Rust 技术重点
第一,bitvec 可以让 IPv4 这种 bit-level 协议字段的序列化变得清晰。比起手写位移,把 u4、u6、u3、u13 这些小整数实现成 BitSerialize,更接近协议结构。
第二,cookie-factory 适合表达"先生成,再回填"的二进制格式。IPv4 total length 和 checksum 都需要先生成完整 buffer,再覆盖特定位置。
第三,网络字节序不能混用。IPv4 length 必须 big-endian;checksum 写回因为当前实现的特殊性使用 little-endian,这是一个有环境假设的实现细节,不能照搬到所有平台。
第四,IPv4 header checksum 只覆盖 header。把整个 packet 拿去算 checksum 是错误的。当前 IHL 固定为 5,所以 header 长度是 20 字节。
第五,ICMP checksum 需要处理奇数长度。payload 可能是任意字节序列,长度不是协议保证的偶数。奇数长度计算时需要隐式补 0。
第六,生命周期不是"值活多久",而是"值在当前位置有效多久"。rawsock interface 借用了 library,不能简单把借用者和被借用者一起移动进结构体。
第七,once_cell 可以把 rawsock library 提升为程序级单例,让 interface 借用 'static library。
第八,Arc 解决跨线程共享所有权,Mutex 解决跨线程可变访问。要保护一个共享可变资源,通常是 Arc<Mutex<T>>,不是 Mutex<Arc<T>>。
第九,Send 和 Sync 的错误提示并不是麻烦,而是在准确指出线程边界上的安全要求。闭包要放到后台线程里,就必须保证捕获的数据可以安全跨线程移动。
第十,抽象封装很重要。最终 sup 的代码不需要知道 ARP、Ethernet、Npcap 细节,只需要注册期望的 IPv4 包、发送 IPv4 payload、等待 channel 结果。这是前面十几篇持续重构的成果。
二十五、还有哪些不足
虽然最后已经做出了自己的 ping,但这个实现仍然是学习项目,不是生产级网络栈。
首先,它只覆盖了必要协议子集。Ethernet、ARP、IPv4、ICMP 都只实现了本项目需要的部分。IPv4 options、分片、更多 ICMP 类型、更完整的 ARP 校验、IPv6、路由选择、多网卡复杂场景,都没有完整支持。
其次,错误处理仍然有不少简化。部分地方使用 unwrap() 或 unimplemented!(),这对文章演示没问题,但完整工具应该返回结构化错误。
再次,checksum 实现依赖当前平台假设。它利用小端机器上的表示方式处理 checksum 写回和奇数长度。如果想写成通用库,应该更严谨地按网络字节序实现。
另外,后台监听线程生命周期还比较粗糙。线程退出、资源释放、错误传播、接口关闭等问题都没有做成完整框架。对命令行小工具来说可以接受,但对长期运行服务不够。
最后,使用 rawsock/Npcap 这类底层接口本身就和平台、权限、驱动版本有关。不同系统、不同网卡、不同 offload 设置可能会表现不同。这个系列的目标是学习和理解,不是交付一个跨平台稳定网络栈。
二十六、总结
这一篇是整个 "Making our own ping" 系列的收尾。它先解决 IPv4 序列化问题:IPv4 头部有大量非整字节字段,手写位运算虽然可行,但不够清晰。通过 bitvec,项目可以用 bit vector 拼接 version/IHL、DSCP/ECN、flags/fragment offset 等字段;再通过 cookie-factory 组合普通字节字段,先生成 length 和 checksum 都为 0 的 IPv4 packet,然后回填 total length 和 header checksum。
接着,文章修复了几个真实错误。total length 不能用 little-endian 写,必须用 big-endian;IPv4 checksum 不能覆盖整个 packet,只能覆盖 header;ICMP checksum 不能假设输入长度是偶数,奇数长度要按 RFC 792 的规则补一个 0 字节参与计算。修复后,项目终于能自己构造 ICMP Echo Request,包进 IPv4,再包进 Ethernet,发往通过 ARP 查询出的网关 MAC,并收到 8.8.8.8 返回的 Echo Reply。
后半部分把旧的 sup 工具迁移到新的 ersatz 协议栈。ersatz 从 binary 改成 library,提供 Interface::open_default()、send_ipv4()、expect_ipv4() 这样的 API。这个过程中遇到了 Rust 系统编程里非常典型的问题:rawsock interface 借用 library,不能构造简单自引用结构;后台线程要求 'static;listener 闭包要 Send;共享可变 pending 队列要用 Arc<Mutex<_>>;interface 要用 Arc 在主线程和后台线程之间共享。最终,Interface 能打开默认网卡,先通过 ARP 拿到网关 MAC,再启动后台 packet processor,用户可以注册条件等待某个 IPv4 包。
最终的 sup 已经是真正意义上的自制 ping。它循环构造 ICMP Echo Request,identifier 固定,sequence number 递增,payload 固定;先注册 Echo Reply listener,再发送 IPv4 packet;收到匹配的 Echo Reply 后打印 Reply from ... bytes=... time=... TTL=...。一开始 RTT 异常接近 1 秒,最后发现是 rawsock/Npcap 的读取 timeout 设置为 1000ms,改成本地 vendor 版本并把 timeout 改为 1ms 后,输出接近系统 ping。
到这里,整个系列终于完成闭环:我们不只是调用系统 API,也不只是抓包观察,而是自己实现了 Ethernet、ARP、IPv4、ICMP 的关键解析和序列化路径,并真正把自制 ICMP Echo Request 发上网络,收到了远端回应。这个 ping 当然还不完整,也不适合作为生产级网络栈,但它已经足够证明:从 Rust 出发,一步步穿过 FFI、Win32、Npcap、二进制解析、checksum、ARP、线程同步和协议封装,确实可以做出自己的 ping。