本文是对 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)
文章主要分为四条线:
- 回顾当前 ping 项目的进展:已经能抓以太网帧,但 ICMP 过滤方式很粗糙。
- 给以太网帧增加真正的 payload 解析:如果
EtherType是 IPv4,就继续解析 IPv4 包。 - 实现 IPv4 头部解析:解析源 IP、目标 IP、协议号、校验和等字段。
- 解决 bit 级解析问题:用
nom::bits、ux、自定义 trait、宏和paste处理u4、u6、u3、u13这类字段。
一、现在项目走到哪一步了?
在这个系列的前 10 篇里,项目已经绕了很长一圈。
一开始只是想自己写一个 ping,但为了真正理解 ping 背后的网络行为,前面已经讨论过计算机之间如何通信、网络标准和协议如何演进;然后项目在 Windows 上绑定 Win32 API,用系统提供的 ICMP 能力实现过一个 ping;接着又深入 WMI 和 Win32 API,只为了找到"默认网络接口";再后来开始查看原始网络流量,并用 nom 解析以太网帧;第 10 篇则暂时停下来,把 Rust 错误处理从 unwrap、expect、panic 梳理成更合理的 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_type 从 EtherType 变成了 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。移除之前那个字符串过滤后,还能看到 0x06 和 0x11,分别对应 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 个字节,再解析 protocol、checksum、src 和 dst。这仍然不是完整 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.250。224.0.0.0 到 239.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_type 和 payload。如果 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 包,也可以跳过 checksum、protocol 这类当前暂时不想展示的字段,只留下更重要的 src、dst 和 payload。原文通过这个 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 有 u8、u16、u32、u64、u128,但没有内置的 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,后面还有 u6、u2、u3、u13。如果全靠手写位运算,代码会变得又脆弱又难读。
所以要引入更抽象的方式。
十、ux:给 Rust 补上 u4、u6、u13 这类整数
原文引入了 ux crate。这个 crate 提供了很多非标准位宽的整数类型,比如 u1 到 u127 之间那些 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)
}
}
但这不合法。
因为 u4 是 ux 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,就可能违反规则;而 ErrorConvert 是 nom 自己定义的 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 头部里不仅有 version 和 ihl,还有这些字段:
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);
但这有一个小问题:信息重复了。u4 和 4 本来就是同一件事,如果不小心写成 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 头需要的所有非标准位宽整数。原文在这一段把 ux、nom::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_u8、be_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)
flags 和 fragment_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。不要在上层靠字符串猜下层协议。
第二,未知协议不一定是错误。遇到不认识的 EtherType 或 Protocol,可以先表示成 None 或 Unknown,而不是直接让 parser 失败。抓包程序面对的是现实网络,现实网络里一定会有很多你暂时不关心的包。
第三,payload 应该进入数据结构。Frame 里放 Payload::IPv4(Packet),Packet 里放 Payload::ICMP(...),这种层层嵌套的结构更接近协议本身,也方便上层代码做模式匹配。
第四,bit 级字段不能简单地靠 &[u8] 表示剩余输入。只要 parser 可以停在半字节位置,就必须额外记录 bit offset。nom::bits 用 (&[u8], usize) 来解决这个问题。
第五,bits(...) 不要随便拆开用。如果两个字段共享同一个字节,比如 version 和 ihl,就应该在同一次 bits(tuple(...)) 里解析。不然中间剩余的 bit 可能会被丢掉,导致后续字段错位。
第六,Rust 的类型可以帮助表达协议含义。用 u4、u6、u13 比全都用 u8、u16 更精确。虽然这些类型不是标准库内置的,但可以借助 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。