最后一块拼图:用 bitvec 构造 IPv4 包,真正做出自己的 Ping

本文是对 Crafting ICMP-bearing IPv4 packets with the help of bitvec 的整理与翻译。

内容结构概览

  1. 为什么 IPv4 序列化比 ICMP 更麻烦:IPv4 有 4-bit、3-bit、13-bit 这类非整字节字段。
  2. 引入 bitvec :用 bit-level vector 处理 version + IHLflags + fragment offset 这类字段。
  3. 封装 bit serializer :在 serialize.rs 中定义 BitOutputbits()WriteLastNBitsBitSerialize
  4. 用宏支持 ux 类型 :为 u2u3u4u6u13 批量实现 bit 序列化。
  5. 实现 IPv4 序列化骨架:写出 version/IHL、DSCP/ECN、length、identification、flags、TTL、protocol、checksum、src/dst、payload。
  6. 回填 IPv4 total length 和 header checksum:先生成 buffer,再填长度,再只对 header 计算 checksum。
  7. 修复两个典型错误:length 应该用 big-endian;IPv4 checksum 只覆盖 header,不覆盖整个 packet。
  8. 真正发送 ICMP Echo Request:把 ICMP 包包进 IPv4,再包进 Ethernet,目标 MAC 使用上一节 ARP 查询出的网关 MAC。
  9. 修复 Ethernet IPv4 payload 未实现 :补上 EtherType::IPv4 的序列化分支。
  10. 修复 ICMP 奇数长度 checksum:payload 长度可能为奇数,checksum 计算需要隐式补 0。
  11. 第一次成功收到 Echo Reply :自制协议栈真正 ping 通 8.8.8.8
  12. sup 迁移到 ersatz :把 ersatz 从 binary 改成 library,提供 Interface API。
  13. 设计 Interface::open_defaultsend_ipv4expect_ipv4:抽象 ARP、发送 IPv4、等待特定包。
  14. rawsock 生命周期问题DynamicInterface 借用了 Library,不能简单把二者一起移动进结构体。
  15. once_cell 管理 rawsock library :让 rawsock library 具备 'static 生命周期。
  16. Arc 管理共享 interface:监听线程和主线程都需要持有同一个网卡接口。
  17. Arc<Mutex<PendingQueries>> 管理监听器:跨线程共享可变等待队列。
  18. 理解 SendSync:闭包、channel、listener 放进后台线程时必须满足线程安全约束。
  19. 启动后台 packet processor:解析收到的 Ethernet frame,把 IPv4 包交给注册的 listener。
  20. 用 scoped thread 先完成 ARP 查询open_default() 返回前先拿到网关 MAC。
  21. sup 功能完成:循环发送 4 次 Echo Request,等待 Echo Reply,打印 bytes、time、TTL。
  22. 修复 1 秒延迟问题:发现 rawsock/Npcap 读取超时为 1000ms,改为 1ms 后 RTT 接近系统 ping。
  23. 最终成果:约 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 生命周期、线程、ArcMutexSendSync 等一连串工程问题。

最后,整个系列终于收束:自制 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-factorybe_u8be_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,比如 u4u6u3u13。这些类型可以表达"这个字段只有多少 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 还需要 u2u3u6u13。手写多个实现很机械,于是可以用宏批量生成:

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>() 会把输入分成 headwordstail。如果有奇数字节,最后那个字节会落在 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 内部引用就悬空了。

一开始可能会想:那就把 libiface 都放进同一个结构体里。问题是,这会形成一种自引用结构。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()。这样主线程和后台线程都能使用同一个网卡接口,而不会违反所有权规则。

这里也体现出 BoxRcArc 的区别。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,因为它必须拥有 ftx。如果只是借用它们,expect_ipv4() 返回后借用对象就失效了。

第三,FT 都要 Send + 'static。listener 会被放进后台线程使用,闭包捕获的东西必须能跨线程发送,并且不能借用短生命周期局部变量。对这个场景来说,这个要求合理:过滤函数通常只捕获目标 IP、identifier、sequence number 这类可复制的小值。


十八、为什么需要 Arc<Mutex<PendingQueries>>

pending 需要被两个线程访问。主线程会通过 expect_ipv4() 添加 listener;后台 packet processor 线程会收到包后遍历 listener,并在匹配成功后移除 listener。也就是说,它既要被多个线程共享,又要被修改。

只用 Arc<PendingQueries> 不够。Arc 只解决共享所有权,不解决内部可变性,也不保证同时修改安全。PendingQueries 里面还有 mpsc::SenderBox<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_cellArcMutexSendSync、trait object、channel、后台线程、scoped thread 等问题。它不只是网络协议文章,也是一次非常真实的 Rust 系统工程实践。


二十四、这一篇的 Rust 技术重点

第一,bitvec 可以让 IPv4 这种 bit-level 协议字段的序列化变得清晰。比起手写位移,把 u4u6u3u13 这些小整数实现成 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>>

第九,SendSync 的错误提示并不是麻烦,而是在准确指出线程边界上的安全要求。闭包要放到后台线程里,就必须保证捕获的数据可以安全跨线程移动。

第十,抽象封装很重要。最终 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/IHLDSCP/ECNflags/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。

相关推荐
用户8356290780511 分钟前
使用 Python 设置 Excel 页眉和页脚
后端·python
步行cgn4 分钟前
IoC 控制反转:从概念到 Spring 的实现
后端
爱勇宝19 分钟前
没有 Fn 键关触控板?我做了一个双击即用的 Windows 小工具
前端·后端·程序员
沙湖遇雨1 小时前
8.ByteBuf 的数据模型、生命周期和内存分配
后端
by组态1 小时前
Ricon组态系统API参考手册
前端·后端·物联网
再吃一根胡萝卜1 小时前
Agent 如何选择与生成 SQL —— Text2SQL 全链路详解
后端
苍何1 小时前
把操作流程直接录成 Skill,豆包太夯了!
后端
Go_error2 小时前
在 Go 服务层中如何优雅地管理事务?
后端·go