从以太网帧到 IPv4 包:Rust + nom 如何解析小于 1 字节的字

本文是对 Parsing IPv4 packets, including numbers smaller than bytes 的整理与翻译。

内容结构概览

这篇文章接在第 10 篇错误处理之后,终于回到网络包解析本身。前面我们已经能抓取默认网卡上的原始流量,并且用 nom 解析以太网帧;但当时为了筛选 ICMP 流量,代码用了一个很临时的办法:检查数据包里是否包含某个字符串。这显然不是真正的协议解析。这一篇要做的事情,就是从以太网帧的 payload 继续往下解析 IPv4 包,然后根据 IPv4 里的 Protocol 字段判断它是不是 ICMP。原文标题里的 "numbers smaller than bytes",指的就是 IPv4 头里有不少字段不是 8 bit、16 bit 这种整字节大小,而是 4 bit、6 bit、3 bit、13 bit 这样的"半字节"或"不按字节对齐"的数字。(fasterthanli.me)

文章主要分为四条线:

  1. 回顾当前 ping 项目的进展:已经能抓以太网帧,但 ICMP 过滤方式很粗糙。
  2. 给以太网帧增加真正的 payload 解析:如果 EtherType 是 IPv4,就继续解析 IPv4 包。
  3. 实现 IPv4 头部解析:解析源 IP、目标 IP、协议号、校验和等字段。
  4. 解决 bit 级解析问题:用 nom::bitsux、自定义 trait、宏和 paste 处理 u4u6u3u13 这类字段。

一、现在项目走到哪一步了?

在这个系列的前 10 篇里,项目已经绕了很长一圈。

一开始只是想自己写一个 ping,但为了真正理解 ping 背后的网络行为,前面已经讨论过计算机之间如何通信、网络标准和协议如何演进;然后项目在 Windows 上绑定 Win32 API,用系统提供的 ICMP 能力实现过一个 ping;接着又深入 WMI 和 Win32 API,只为了找到"默认网络接口";再后来开始查看原始网络流量,并用 nom 解析以太网帧;第 10 篇则暂时停下来,把 Rust 错误处理从 unwrapexpectpanic 梳理成更合理的 Result 和自定义错误。(fasterthanli.me)

到了第 11 篇,重点重新回到抓包和解析。

目前项目里有一个 ersatz 二进制程序,它能在默认网络接口上监听数据包,然后把每个包交给 process_packet()。这个函数已经可以解析以太网帧,也就是拿到目标 MAC、源 MAC 和 EtherType

但为了避免输出太多无关流量,之前临时用了一个很别扭的过滤方式:检查包里是否包含某个字符串,比如 "abcdefghijkl"。这样做的动机是:Windows 自带的 PING.exe 发出的 ICMP payload 里刚好带了这段数据。但这不是协议层面的过滤。如果你在网页表单里提交这个字符串,它也可能被误判;反过来,如果其他程序发送的 ICMP 包没有这个 payload,它又会被漏掉。原文也明确说,这种过滤方式早晚要被真正的过滤逻辑替换掉。(fasterthanli.me)

真正的过滤应该看协议字段。

ICMP 不是靠 payload 里的某段字符串识别的,而是 IPv4 头部里有一个 Protocol 字段。对于 ICMP,这个字段值是 0x01;对于 TCP 是 0x06;对于 UDP 是 0x11。所以要正确过滤 ICMP,下一步就必须解析 IPv4 包。(fasterthanli.me)


二、从以太网帧继续往下看:payload 才是重点

以太网帧的结构大致是:

text 复制代码
目标 MAC | 源 MAC | EtherType | Payload

前面我们已经解析了前三部分。关键是 EtherType。如果它的值是 0x0800,说明 payload 是 IPv4 包。

之前的以太网解析器大概只关心这些信息:

rust 复制代码
pub struct Frame {
    pub dst: Addr,
    pub src: Addr,
    pub ether_type: EtherType,
}

但这样还不够。因为以太网帧不是终点,它只是链路层的一层包装。我们真正要看的 ICMP 在更里面:以太网帧的 payload 是 IPv4,IPv4 的 payload 才可能是 ICMP。

所以结构要变成这样:

rust 复制代码
pub struct Frame {
    pub dst: Addr,
    pub src: Addr,
    pub ether_type: Option<EtherType>,
    pub payload: Payload,
}

pub enum Payload {
    IPv4(ipv4::Packet),
    Unknown,
}

这里有两个细节很重要。

第一,ether_typeEtherType 变成了 Option<EtherType>。这意味着:遇到未知的 EtherType,不再直接报错,而是解析成 None。因为网卡上会看到很多非 IPv4 包,比如 ARP。之前 parser 碰到不认识的 0x0806 会报错,但这其实不是"坏包",只是我们暂时不想解析它。原文在移除临时过滤后,很快就遇到了这种未知 EtherType,于是把它改成了 Option。(fasterthanli.me)

第二,payload 变成枚举。这样 Frame 不只是告诉你"这是 IPv4",还可以真的把 IPv4 包解析出来:

rust 复制代码
pub enum Payload {
    IPv4(ipv4::Packet),
    Unknown,
}

这种设计比到处传 remaining 切片更清楚。上层代码看到 Frame,就能通过模式匹配进入下一层协议。


三、先用最粗糙的方式看一眼 IPv4 Protocol 字段

在正式写 IPv4 parser 前,原文先做了一个实验。

以太网 parser 返回两个东西:

rust 复制代码
Ok((remaining, frame))

其中 frame 是已经解析好的以太网帧,remaining 是还没被消费的 payload。如果 EtherType 是 IPv4,那么 remaining 开头就是 IPv4 包。

IPv4 头部里的 Protocol 字段位于第 10 个字节,也就是偏移量 9。于是可以先粗暴地写:

rust 复制代码
let protocol = remaining[9];
println!("protocol = 0x{:02X}", protocol);

运行后发现,ping 包对应的 protocol 是 0x01。这正是 ICMP。移除之前那个字符串过滤后,还能看到 0x060x11,分别对应 TCP 和 UDP。(fasterthanli.me)

这个实验很有价值。它证明了方向是对的:只要能解析 IPv4,就能从协议字段判断 payload 类型,而不需要靠 payload 内容猜测。

但这种 remaining[9] 只是临时验证。真正的解析器不能到处写硬编码偏移。我们需要把 IPv4 包建模出来。


四、设计 IPv4 Packet 结构

接下来创建 ipv4::Packet

一开始可以先放几个容易解析的字段:

rust 复制代码
pub struct Packet {
    pub protocol: Option<Protocol>,
    pub src: Addr,
    pub dst: Addr,
    pub checksum: u16,
    pub payload: Payload,
}

pub enum Protocol {
    ICMP = 0x01,
    TCP = 0x06,
    UDP = 0x11,
}

pub enum Payload {
    Unknown,
}

这里的 Protocol 和前面的 EtherType 很像,也可以用 derive_try_from_primitive 来把数字转成枚举。遇到不认识的协议号,就返回 None,不强行报错。

IPv4 地址也很简单,就是 4 个字节:

rust 复制代码
pub struct Addr([u8; 4]);

解析时从输入里取 4 字节,然后复制到数组里即可。

此时 Packet::parse() 可以先跳过前 9 个字节,再解析 protocolchecksumsrcdst。这仍然不是完整 IPv4 解析,但已经足够让我们看到真实的源 IP、目标 IP 和协议类型。原文也正是先这样做:暂时跳过还没处理的字段,把当前最关心的字段解析出来。(fasterthanli.me)

示意代码可以写成:

rust 复制代码
impl Packet {
    pub fn parse(i: parse::Input) -> parse::Result<Self> {
        let (i, _) = take(9usize)(i)?;
        let (i, protocol) = Protocol::parse(i)?;
        let (i, checksum) = be_u16(i)?;
        let (i, (src, dst)) = tuple((Addr::parse, Addr::parse))(i)?;

        Ok((i, Self {
            protocol,
            checksum,
            src,
            dst,
            payload: Payload::Unknown,
        }))
    }
}

这段代码体现了 nom 的典型风格:每一步都接收输入切片,返回剩余输入和解析结果。解析器之间可以组合,最后形成一个更大的 parser。


五、让 Ethernet parser 根据 EtherType 解析 payload

有了 ipv4::Packet::parse() 之后,就可以回到以太网解析器。

之前以太网帧只解析头部:

rust 复制代码
dst
src
ether_type

现在要根据 ether_type 决定是否继续解析 payload:

rust 复制代码
let (i, payload) = match ether_type {
    Some(EtherType::IPv4) => {
        map(ipv4::Packet::parse, Payload::IPv4)(i)?
    }
    None => {
        (i, Payload::Unknown)
    }
};

也就是说,如果 EtherType 是 IPv4,就调用 ipv4::Packet::parse();否则就保留为 Unknown

这个结构比之前的 tuple(...) 更长一点,但逻辑更明确:以太网层负责识别下一层协议,并把解析权交给下一层 parser。原文也提到,为了根据 EtherType 条件解析 payload,需要稍微调整代码结构,但最后可读性反而更好。(fasterthanli.me)

此时打印结果已经不只是:

text 复制代码
Frame { dst, src, ether_type }

而是可以看到:

text 复制代码
Frame {
    dst: ...,
    src: ...,
    ether_type: Some(IPv4),
    payload: IPv4(
        Packet {
            protocol: Some(TCP),
            src: 35.186.224.53,
            dst: 192.168.1.16,
            checksum: ...,
            payload: Unknown,
        }
    )
}

这时抓到的网络包就开始"像协议"了,而不是一堆原始字节。


六、从抓包结果理解本机网络流量

原文展示了几种真实抓包结果。

有一个 TCP 包从 Google 的 IP 到本机局域网地址 192.168.1.16。这是从无线网卡上抓到的,所以目标地址是局域网 IP,而不是公网 IP。这个现象很正常:公网地址通常在路由器或 NAT 设备那一层处理,到了本机网卡上,看到的是局域网地址。(fasterthanli.me)

另一个 UDP 包从本机发往 239.255.255.250224.0.0.0239.255.255.255 这类地址用于 IP multicast,也就是多播。原文把它称作 broadcast 有点口语化,但技术上更准确地说是 multicast。它常用于局域网发现、媒体分发等场景。(fasterthanli.me)

还有一个 TCP 包发往 Microsoft 相关 IP。考虑到作者当时是在 Windows 10 上跑程序,这并不奇怪。操作系统和后台服务会有各种联网行为,比如遥测、在线状态检查、更新检查等。(fasterthanli.me)

这些例子说明一件事:只要开始监听默认网卡,就会看到大量真实网络流量。你以为自己只是 ping 一下,但机器上同时还有 DNS、HTTP、HTTPS、系统服务、局域网发现等各种包。

所以过滤必须建立在协议解析之上,而不是在 payload 里搜字符串。


七、清理 Debug 输出,让日志更像人能读的东西

当结构体字段越来越多,Debug 输出会变得非常吵。

比如 Frame 里同时有 ether_typepayload。如果 payload 已经是 Payload::IPv4(...),那 ether_type: Some(IPv4) 就有点重复。Packet 里也有 protocol 字段,但 payload 以后也会表达具体协议类型。

于是原文引入了 custom_debug_derive,让 Debug 输出更可控。比如:

rust 复制代码
#[derive(CustomDebug)]
pub struct Frame {
    pub dst: Addr,
    pub src: Addr,

    #[debug(skip)]
    pub ether_type: Option<EtherType>,

    pub payload: Payload,
}

对于 IPv4 包,也可以跳过 checksumprotocol 这类当前暂时不想展示的字段,只留下更重要的 srcdstpayload。原文通过这个 crate 把输出清理成更适合观察的形式。(fasterthanli.me)

清理后,输出更像这样:

text 复制代码
Frame {
    dst: ...,
    src: ...,
    payload: IPv4(
        Packet {
            src: 8.8.8.8,
            dst: 192.168.1.16,
            payload: Unknown,
        }
    )
}

这对调试很有帮助。抓包时日志量本来就大,如果每个包都打印一堆暂时无关字段,很快就看不下去了。


八、真正过滤 ICMP

现在 Frame 里已经有 Payload::IPv4(packet),而 packet 里有 protocol 字段,所以 ICMP 过滤终于可以写得像样了:

rust 复制代码
if let ethernet::Payload::IPv4(ref packet) = frame.payload {
    if let Some(ipv4::Protocol::ICMP) = packet.protocol {
        println!("{:?} | {:#?}", now, packet);
    }
}

这里有一个 Rust 细节:为什么要写 ref packet

因为 frame.payload 属于 frame。模式匹配时如果不加 ref,可能会把 payload 从 frame 里 move 出来。我们这里只是想借用它,用来判断和打印,并不想拿走所有权。所以写成 ref packet。原文也专门解释了这个点。(fasterthanli.me)

然后再运行 ping,比如 ping rust-lang.org,另一边运行我们的抓包程序,就能只看到 ICMP 相关 IPv4 包:

text 复制代码
Packet {
    src: 192.168.1.16,
    dst: 143.204.229.8,
    payload: Unknown,
}

Packet {
    src: 143.204.229.8,
    dst: 192.168.1.16,
    payload: Unknown,
}

这说明过滤逻辑已经从"payload 字符串匹配"升级成了"协议字段匹配"。

这一步非常关键。因为后面如果要继续解析 ICMP,就可以在 Protocol::ICMP 的分支里,把 IPv4 的 payload 交给 icmp::Packet::parse()


九、真正麻烦的地方来了:IPv4 里有小于 1 字节的字段

到目前为止,我们为了快点看到效果,跳过了 IPv4 头部前面的若干字节。

但 IPv4 头部不是所有字段都整整齐齐按字节排好的。最典型的是第一个字节:

text 复制代码
Version: 4 bits
IHL:     4 bits

这两个字段加起来刚好 1 字节,但每个字段只有 4 bit。

Rust 有 u8u16u32u64u128,但没有内置的 u4。原文也正是从这里开始进入 "numbers smaller than bytes" 的主题。(fasterthanli.me)

最直接的办法是手动位运算:

rust 复制代码
let byte = be_u8(i)?;
let version = byte >> 4;
let ihl = byte & 0x0F;

这当然能工作。比如 IPv4 包的 version 应该是 4,常见无 options 的 IPv4 header 长度 ihl 是 5。IHL 表示 Internet Header Length,单位是 32-bit word。ihl = 5 就是 5 个 32-bit word,也就是 20 字节。(fasterthanli.me)

但原文不想一直手写位移和掩码。因为 IPv4 头里不只有 u4,后面还有 u6u2u3u13。如果全靠手写位运算,代码会变得又脆弱又难读。

所以要引入更抽象的方式。


十、ux:给 Rust 补上 u4、u6、u13 这类整数

原文引入了 ux crate。这个 crate 提供了很多非标准位宽的整数类型,比如 u1u127 之间那些 Rust 内置类型没有覆盖的宽度。这样我们就可以在结构体里写:

rust 复制代码
use ux::*;

pub struct Packet {
    pub version: u4,
    pub ihl: u4,
}

这在表达上比 u8 更准确。

如果字段本来只有 4 bit,那么类型上写 u4 就比注释里写"actually 4 bits"要好。类型本身就说明了字段宽度。

但只引入 ux 还不够。因为最开始仍然要从字节里取出高 4 bit 和低 4 bit:

rust 复制代码
let version = u4::new(byte >> 4);
let ihl = u4::new(byte & 0x0F);

这只是把结果类型变得更准确了,还没有摆脱手写 bit 操作。

接下来要用 nom 的 bit parser。


十一、nom::bits::take 为什么不能直接用在 &u8 上?

nom 提供了 nom::bits::complete::take,可以按 bit 数量读取输入。看起来我们可以这样写:

rust 复制代码
let version = take_bits(4usize)(i)?;
let ihl = take_bits(4usize)(i)?;

但这会报错。

原因是:过去所有 parser 都工作在字节切片上,也就是 &[u8]。一个 byte-level parser 接收一段字节,返回剩下的字节。

但 bit-level parser 不一样。假设只读取 4 bit,剩下的输入位置就停在一个字节中间。此时只返回 &[u8] 不够,因为切片只能从字节边界开始,不能从"半个字节"开始。

所以 bit-level parser 的输入不是单纯的 &[u8],而是:

rust 复制代码
(&[u8], usize)

前者表示还剩哪些字节,后者表示当前在第几个 bit 偏移处。原文通过一系列图解释了这一点:如果解析器处于半字节位置,就必须额外保存 bit offset,否则无法准确表示"剩余输入"。(fasterthanli.me)

于是解析辅助类型要扩展:

rust 复制代码
pub type Input<'a> = &'a [u8];
pub type Result<'a, T> = nom::IResult<Input<'a>, T, Error<Input<'a>>>;

pub type BitInput<'a> = (&'a [u8], usize);
pub type BitResult<'a, T> = nom::IResult<BitInput<'a>, T, Error<BitInput<'a>>>;

这样以后普通 parser 用 Input,bit parser 用 BitInput


十二、不能给 u4 直接实现 parse,因为孤儿规则

接下来很自然地想写:

rust 复制代码
impl u4 {
    fn parse(i: BitInput) -> BitResult<Self> {
        map(take_bits(4usize), Self::new)(i)
    }
}

但这不合法。

因为 u4ux crate 里的类型,不是我们自己定义的类型。Rust 不允许你随便给外部 crate 的类型添加 inherent impl。也就是说,不能直接给 u4 增加一个新的关联函数 parse

解决办法是定义一个自己的 trait:

rust 复制代码
pub trait BitParsable
where
    Self: Sized,
{
    fn parse(i: BitInput) -> BitResult<Self>;
}

然后为 u4 实现这个 trait:

rust 复制代码
impl BitParsable for u4 {
    fn parse(i: BitInput) -> BitResult<Self> {
        map(take_bits(4usize), Self::new)(i)
    }
}

这就合法了。因为 trait 是我们自己定义的,即使类型来自外部 crate,也可以为它实现本地 trait。

这正是 Rust 的孤儿规则在发挥作用:你不能同时"借别人的 trait"和"借别人的类型"来写 impl;但只要 trait 或类型有一个是你自己的,就可以实现。原文后面在解释 ErrorConvert 时也再次提到孤儿规则。(fasterthanli.me)


十三、nom::bits::bits:在字节解析和 bit 解析之间切换

有了 BitParsable 之后,我们仍然面临一个问题:Packet::parse() 是普通 byte-level parser,它接收 &[u8];但 u4::parse() 是 bit-level parser,它接收 (&[u8], usize)

nom 提供了一个转换器:nom::bits::bits

它的作用是:把 byte-level input 转换成 bit-level input,让内部 parser 消费 bit;结束后再把剩余输入转换回 byte-level input。原文引用的文档也强调了这一点:它会把输入转到 bit 级,内部 parser 结束后再转回字节级,并丢弃剩余 bits。(fasterthanli.me)

于是我们可能会写:

rust 复制代码
let (i, version) = bits(u4::parse)(i)?;
let (i, ihl) = bits(u4::parse)(i)?;

看起来很合理,但这会埋下一个 bug。

在此之前,还会先遇到一个编译问题:错误类型不匹配。因为 bit parser 的错误输入类型是 (&[u8], usize),而外层 parser 的错误输入类型是 &[u8]。两者需要转换。


十四、ErrorConvert:把 bit-level 错误转回 byte-level 错误

nom 为这个场景提供了 ErrorConvert

为什么不用标准库里的 From?原文解释说,这是为了避免孤儿规则带来的限制。From 定义在标准库里,如果 nom 想给外部类型实现 From,就可能违反规则;而 ErrorConvertnom 自己定义的 trait,它可以在自己的生态里处理这些转换问题。(fasterthanli.me)

在当前项目中,我们拥有自己的 parse::Error<I>,所以可以实现:

rust 复制代码
impl<I> ErrorConvert<Error<I>> for Error<(I, usize)>
where
    I: Slice<RangeFrom<usize>>,
{
    fn convert(self) -> Error<I> {
        let errors = self.errors
            .into_iter()
            .map(|((rest, offset), err)| {
                (rest.slice(offset / 8..), err)
            })
            .collect();

        Error { errors }
    }
}

这里做的事情是:把 bit-level 的错误位置转换成 byte-level 的错误位置。由于 byte-level 无法表示半字节位置,所以只能切到附近的字节边界。原文也说,这里必须"在某个地方切一刀"。(fasterthanli.me)

这一步完成后,bits(...) 和自定义错误类型终于能一起工作。


十五、一个很隐蔽的 bug:分两次 bits 会丢掉半个字节

现在代码能编译了,但运行后出现奇怪结果:

text 复制代码
version = 4, ihl = 0
version = 4, ihl = 0

version = 4 是对的,但 ihl = 0 明显不对。正常 IPv4 包一般应该是 ihl = 5

问题出在哪里?

在这段代码:

rust 复制代码
let (i, version) = bits(u4::parse)(i)?;
let (i, ihl) = bits(u4::parse)(i)?;

每一次 bits(...) 都会把输入转成 bit-level,让内部 parser 解析,然后再转回 byte-level。关键在于:如果内部 parser 停在半字节位置,剩余 bit 会被丢弃。

第一次解析 version,只消费了前 4 bit。然后 bits 转回字节级时,把同一个字节剩下的 4 bit 丢了。第二次解析 ihl 时,已经从下一个字节开始了。

也就是说,这段代码实际效果变成了:

text 复制代码
读取第 1 个字节高 4 bit -> version
丢掉第 1 个字节低 4 bit
读取第 2 个字节高 4 bit -> ihl
丢掉第 2 个字节低 4 bit

所以 ihl 就错了。

正确做法是:在同一次 bits(...) 调用里把两个 4 bit 字段都读完:

rust 复制代码
let (i, (version, ihl)) = bits(|i| {
    let (i, version) = u4::parse(i)?;
    let (i, ihl) = u4::parse(i)?;
    Ok((i, (version, ihl)))
})(i)?;

这样内部 parser 先读前 4 bit,再读后 4 bit,刚好回到字节边界,然后再返回 byte-level。原文修正后,输出变成了 version = 4, ihl = 5,说明解析正确。(fasterthanli.me)

这个 bug 很值得记住:bit parser 不要随便拆成多次 bits(...) 调用。只要字段之间共享同一个字节,就应该放在同一次 bit-level 解析里。


十六、用 tuple 简化 bit parser

刚才的 closure 能工作,但写起来有点啰嗦。

nom::sequence::tuple 足够泛型,不仅能组合 byte-level parser,也能组合 bit-level parser。所以可以改成:

rust 复制代码
let (i, (version, ihl)) =
    bits(tuple((u4::parse, u4::parse)))(i)?;

这比手写 closure 更简洁,也更符合 nom 的组合式风格。原文把这一步称为更"工业化"的 bit parsing:不是靠临时拼接,而是把小 parser 组合成更大的 parser。(fasterthanli.me)

接下来还能顺手验证 version 字段:

rust 复制代码
if u8::from(version) != 4 {
    return Err(...);
}

因为我们解析的是 IPv4 包,所以 version 必须是 4。如果不是,那说明上层判断错了,或者数据包不是合法 IPv4。原文这里用自定义错误返回一个更清楚的错误消息。(fasterthanli.me)

这也体现了一个好习惯:协议解析不只是"读字段",还应该检查字段是否符合协议约束。


十七、IPv4 头里还有哪些非整字节字段?

接下来要补全 IPv4 header。

IPv4 头部里不仅有 versionihl,还有这些字段:

text 复制代码
Version:         4 bits
IHL:             4 bits
DSCP:            6 bits
ECN:             2 bits
Total Length:    16 bits
Identification:  16 bits
Flags:           3 bits
Fragment Offset: 13 bits
TTL:             8 bits
Protocol:        8 bits
Header Checksum: 16 bits
Source IP:       32 bits
Destination IP:  32 bits

其中 DSCP + ECN 又刚好组成 1 字节,Flags + Fragment Offset 又刚好组成 2 字节。问题是:字段本身不是整字节大小。

所以我们还需要支持:

rust 复制代码
u2
u3
u4
u6
u13

手动为每个类型写一遍 BitParsable 很重复。比如:

rust 复制代码
impl BitParsable for u4 { ... }
impl BitParsable for u6 { ... }
impl BitParsable for u3 { ... }

这时就该用宏。


十八、用宏批量实现 BitParsable

最简单的宏可以这样写:

rust 复制代码
macro_rules! impl_bit_parsable_for_ux {
    ($type:ty, $width:expr) => {
        impl BitParsable for $type {
            fn parse(i: BitInput) -> BitResult<Self> {
                map(take_bits($width as usize), Self::new)(i)
            }
        }
    };
}

然后调用:

rust 复制代码
impl_bit_parsable_for_ux!(u4, 4);
impl_bit_parsable_for_ux!(u6, 6);

但这有一个小问题:信息重复了。u44 本来就是同一件事,如果不小心写成 impl_bit_parsable_for_ux!(u4, 3),编译器不一定能帮你发现语义错误。

更理想的是只传宽度:

rust 复制代码
impl_bit_parsable_for_ux!(4);

宏内部自动拼出 u4

普通 macro_rules! 不能直接把 u$width 拼成一个类型名。于是原文引入了 paste crate,它能做 token pasting。(fasterthanli.me)

paste 后,可以写:

rust 复制代码
macro_rules! impl_bit_parsable_for_ux {
    ($width:expr) => {
        paste::item! {
            impl BitParsable for [<u $width>] {
                fn parse(i: BitInput) -> BitResult<Self> {
                    map(take_bits($width as usize), Self::new)(i)
                }
            }
        }
    };
}

再进一步,让宏支持多个宽度:

rust 复制代码
macro_rules! impl_bit_parsable_for_ux {
    ($($width:expr),*) => {
        $(
            paste::item! {
                impl BitParsable for [<u $width>] {
                    fn parse(i: BitInput) -> BitResult<Self> {
                        map(take_bits($width as usize), Self::new)(i)
                    }
                }
            }
        )*
    };
}

impl_bit_parsable_for_ux!(2, 3, 4, 6, 13);

这样就一次性支持了 IPv4 头需要的所有非标准位宽整数。原文在这一段把 uxnom::bits、自定义 trait、宏和 paste 组合到了一起。(fasterthanli.me)


十九、补全 IPv4 Packet 字段

现在终于可以把 IPv4 头部字段完整写进结构体:

rust 复制代码
pub struct Packet {
    pub version: u4,
    pub ihl: u4,
    pub dscp: u6,
    pub ecn: u2,
    pub length: u16,
    pub identification: u16,
    pub flags: u3,
    pub fragment_offset: u13,
    pub ttl: u8,
    pub protocol: Option<Protocol>,
    pub checksum: u16,
    pub src: Addr,
    pub dst: Addr,
    pub payload: Payload,
}

这时 Packet::parse() 的流程就很接近协议结构本身了:

rust 复制代码
let (i, (version, ihl)) =
    bits(tuple((u4::parse, u4::parse)))(i)?;

let (i, (dscp, ecn)) =
    bits(tuple((u6::parse, u2::parse)))(i)?;

let (i, length) = be_u16(i)?;
let (i, identification) = be_u16(i)?;

let (i, (flags, fragment_offset)) =
    bits(tuple((u3::parse, u13::parse)))(i)?;

let (i, ttl) = be_u8(i)?;
let (i, protocol) = Protocol::parse(i)?;
let (i, checksum) = be_u16(i)?;
let (i, (src, dst)) = tuple((Addr::parse, Addr::parse))(i)?;

这段代码的可读性不错。它基本按照 IPv4 header 顺序往下读。对于跨 bit 边界的字段,用 bits(tuple(...));对于整字节字段,用 be_u8be_u16 或地址 parser。

然后再根据 protocol 决定 payload:

rust 复制代码
let (i, payload) = match protocol {
    Some(Protocol::ICMP) => {
        map(icmp::Packet::parse, Payload::ICMP)(i)?
    }
    _ => {
        (i, Payload::Unknown)
    }
};

这一步开始把 IPv4 和 ICMP 连接起来:如果 IPv4 的 Protocol 是 ICMP,就进入 ICMP parser。否则暂时不解析 payload。


二十、解析结果:字段终于都对上了

运行后,抓到的 ICMP 包里可以看到类似这些字段:

text 复制代码
Packet {
    ihl: 5,
    dscp: 0,
    ecn: 0,
    length: 60,
    identification: 657a,
    flags: 0,
    fragment_offset: 0,
    ttl: 128,
    checksum: 0000,
    src: 192.168.1.16,
    dst: 8.8.8.8,
    payload: Unknown,
}

这些值基本都能解释得通。

IHL = 5,说明 IPv4 header 是 5 个 32-bit word,也就是 20 字节。普通 ICMP ping 不需要 IP options,所以这很合理。(fasterthanli.me)

DSCP = 0,说明没有特别的差分服务标记。DSCP 通常用于 QoS、实时流媒体等场景,普通 ICMP 不需要它。ECN = 0,说明没有显式拥塞通知。(fasterthanli.me)

length = 60,表示整个 IP 包长度是 60 字节。这里包含 20 字节 IPv4 header 和后面的数据。(fasterthanli.me)

identification 用来帮助重组分片后的 IP datagram。发送出去的包上可能会看到操作系统网络栈选择的值;返回包上看到的值可能不同。(fasterthanli.me)

flagsfragment_offset 都和 IP 分片有关。这里它们为 0,说明当前包没有分片。fragment_offset 只有在一个 datagram 被拆成多个 IP 包时才真正有意义。(fasterthanli.me)

ttl 对 ping 来说很有意思。发送出去的包 TTL 是 128,这是前面自己写 ping 工具时指定过的 TTL;返回时 TTL 可能变成 54。每经过一个路由器,TTL 通常会减 1,所以可以粗略看出路径经过了多少跳。原文用 128 - 54 = 74 推了一下往返路径里消耗的跳数。(fasterthanli.me)

最后,源 IP 和目标 IP 仍然正确,说明前面的字段解析没有错位。如果 bit parser 某一步多吃了或少吃了 bit,后面的地址很快就会变成乱码。

这就是协议解析里最真实的反馈:字段能对上,地址能对上,长度能对上,才说明 parser 大概率是对的。


二十一、这一篇真正学到的东西

这篇表面上是在解析 IPv4,实际上还讲了很多 Rust 解析器设计的细节。

第一,协议解析应该逐层推进。以太网帧负责识别 EtherType,IPv4 包负责识别 Protocol,ICMP parser 再负责解析 ICMP payload。不要在上层靠字符串猜下层协议。

第二,未知协议不一定是错误。遇到不认识的 EtherTypeProtocol,可以先表示成 NoneUnknown,而不是直接让 parser 失败。抓包程序面对的是现实网络,现实网络里一定会有很多你暂时不关心的包。

第三,payload 应该进入数据结构。Frame 里放 Payload::IPv4(Packet)Packet 里放 Payload::ICMP(...),这种层层嵌套的结构更接近协议本身,也方便上层代码做模式匹配。

第四,bit 级字段不能简单地靠 &[u8] 表示剩余输入。只要 parser 可以停在半字节位置,就必须额外记录 bit offset。nom::bits(&[u8], usize) 来解决这个问题。

第五,bits(...) 不要随便拆开用。如果两个字段共享同一个字节,比如 versionihl,就应该在同一次 bits(tuple(...)) 里解析。不然中间剩余的 bit 可能会被丢掉,导致后续字段错位。

第六,Rust 的类型可以帮助表达协议含义。用 u4u6u13 比全都用 u8u16 更精确。虽然这些类型不是标准库内置的,但可以借助 ux crate。

第七,宏可以减少重复,但也要避免重复信息。paste 让宏能把 u 和数字拼成类型名,从而写出 impl_bit_parsable_for_ux!(2, 3, 4, 6, 13) 这种简洁接口。

第八,调试输出也是工程体验的一部分。抓包程序的日志量很大,如果 Debug 输出不整理,很难观察真实问题。custom_debug_derive 在这里就很实用。

原文最后也总结了这一篇用到的工具:nom 负责 bit parsing,custom_debug_derive 负责更友好的 Debug 输出,paste 提供宏层面的 token 拼接能力,ux 则提供非 8 倍数位宽的整数类型。(fasterthanli.me)


结语

到了这一篇,自制 ping 项目已经真正开始进入协议解析阶段。

前面我们只是抓到了以太网帧,现在已经能沿着协议层级继续往下走:以太网帧里识别 IPv4,IPv4 里识别 ICMP,并且能把 IPv4 header 里的各种字段解析出来。

这一步看似只是"多解析了一层",但意义很大。因为从这里开始,我们不再依赖 Windows 自带的 ICMP API,也不再靠 payload 里的固定字符串做过滤,而是在自己理解和实现网络协议。

更重要的是,这一篇展示了 Rust 写协议解析时的一个核心优势:可以把协议结构变成类型结构,把 bit 宽度变成类型约束,把 parser 组合成层层递进的解析流程。

下一步,自然就是继续深入 ICMP 本身。IPv4 已经把我们带到了 ICMP payload 的入口,剩下的事情,就是解析真正的 Echo Request 和 Echo Reply。