本文是对 Consuming Ethernet frames with the nom crate 的整理与翻译。
内容结构概览
- 从打开网卡到监听数据包:上一节已经找到默认网卡,这一节开始用
rawsock监听经过网卡的包。 - 先用“笨办法”确认 ICMP 包存在:通过搜索 ping 默认 payload
abcdefghijkl,判断抓到的包里是否可能包含 ICMP Echo。 - 判断抓到的是 Ethernet 帧还是 IP 包:通过读取第 12、13 字节的 EtherType,确认数据从 Ethernet 帧开始。
- 第一次踩坑:大小端问题:直接
transmute读u16得到0x0008,正确值应为0x0800。 - 用
u16::from_be_bytes安全读取大端整数:不再使用 unsafe,并保持跨平台语义正确。 - 解析 MAC 地址:新增
ethernet::Addr,读取目标 MAC、源 MAC 和 EtherType。 - 代码审查与性能验证:检查手写解析代码是否清楚、是否高效,并用 x64dbg 看
from_be_bytes编译后的汇编。 - 引入
ethernet::Frame:把目标 MAC、源 MAC、EtherType 组织成结构体。 - 引入
nom:用 parser combinator 替代手写切片解析,让二进制协议解析更声明式。 - 实现 MAC 地址 parser:用
take(6)和map把 6 字节切片转换为ethernet::Addr。 - 组合 parser 解析 Ethernet 帧:用
tuple((Addr::parse, Addr::parse, be_u16))组合出完整帧解析器。 - 改造 parse 模块:用类型别名简化
nom::IResult、输入类型和错误类型。 - 自定义解析错误:实现
nom::error::ParseError,让错误能记录多个上下文。 - 用
context增强错误信息:区分是在解析 MAC address、EtherType 还是 Ethernet frame 时失败。 - 美化错误输出:打印十六进制片段,并用波浪线标出失败位置。
- 处理真实世界错误:未知 EtherType:把 EtherType 从裸
u16改成 enum,只支持 IPv4,遇到 IPv6 时返回自定义错误。 - 检查 nom 生成代码是否高效:release 构建后用 x64dbg 观察汇编,开启 LTO 后进一步优化。
- 总结:这一篇已经能稳定消费 Ethernet 帧,为后面继续解析 IPv4 和 ICMP 打基础。
前面几篇已经走过了很长一段路。最开始通过 Windows 的 IcmpSendEcho 快速实现了一个能工作的 ping,然后逐步重构出更合理的 Rust API;接着又不满足于让 Windows 帮我们“代发 ICMP”,于是开始转向更底层的数据包收发。上一节已经用 GetIpForwardTable 和 GetInterfaceInfo 找到了默认网络接口,并通过 Npcap/rawsock 打开了对应网卡。也就是说,程序终于站在了一个更靠近网络底层的位置:它可以监听经过默认网卡的原始数据。
这一篇的目标不是发送数据包,而是消费已经抓到的数据包。真正开始自己构造 ICMP 包之前,先要确认抓到的到底是什么。rawsock 给我们的 packet 是一段字节,但这段字节从哪一层开始?它是 IPv4 包,还是完整 Ethernet 帧?如果是 Ethernet 帧,目标 MAC、源 MAC、EtherType 分别在哪些字节?怎样用 Rust 安全、清楚、可维护地把这些字节解析成结构化数据?
这篇会从非常直接的“笨办法”开始:先监听包长,再在包里搜索 ping 默认 payload,确认确实能看到 ICMP 相关数据。然后逐步读取 Ethernet 头部字段,处理大小端问题,构建 MAC 地址类型,最后引入 nom crate,用 parser combinator 把手写切片解析改成更声明式的二进制协议解析器。后半部分会花不少篇幅处理错误:短包怎么报错,解析失败时如何带上下文,如何用十六进制和标记线显示失败位置,以及遇到 IPv6 EtherType 这种“真实世界错误”时,怎样避免一个 unwrap() 把整个抓包程序搞崩。
一、先确认网卡真的能监听到数据
上一节已经能打开默认接口,现在可以直接监听网卡流量。主程序的大致结构是:打开 rawsock 库,拼出默认接口名,用 open_interface 打开接口,然后调用 loop_infinite_dyn 不断处理捕获到的 packet。
代码大概是这样:
use rawsock::open_best_library;
use std::time::Instant;
fn main() -> Result<(), Error> {
let lib = open_best_library()?;
let iface_name = format!(
r#"\Device\NPF_{}"#,
netinfo::default_nic_guid()?
);
let iface = lib.open_interface(&iface_name)?;
println!("Listening for packets...");
let start = Instant::now();
iface.loop_infinite_dyn(&mut |packet| {
println!(
"{:?} | received {} bytes",
start.elapsed(),
packet.len()
);
})?;
Ok(())
}
运行后会不断打印收到多少字节。例如有些包 60 字节,有些 100 字节,有些 1392 字节。仅从长度看,还不能确认这些包是什么协议,也不能确认它们是不是我们关心的 ping 包。但这至少说明网卡监听已经工作,rawsock 正在把经过接口的数据交给程序。
这里没有直接把每个 packet 的原始内容打印出来。网络包里可能包含自己电脑进出的真实流量,直接把它们发布或记录到不安全位置并不好。即使自己暂时看不懂这些字节,别人也可能从中还原出敏感信息。对抓包程序来说,默认不要随便泄露原始流量,是一个值得保留的习惯。
二、用笨办法确认 ICMP Echo 包存在
现在只知道抓到了包,但不知道里面有没有 ICMP。前面研究 Windows 自带 ping.exe 时,已经观察到它默认会发送一段小写字母 payload。ICMP Echo Request 的 payload 通常不会被压缩,也不会被加密,所以如果后台一直运行:
ping 8.8.8.8 -t
那么理论上,在抓到的 packet 字节里应该能找到类似:
abcdefghijkl
这样的连续字节序列。
第一反应可能是直接对 packet 调 .find("abcdefghijkl")。但 packet 本质上可以当作字节切片看待,而 Rust 的 &[u8] 没有字符串那样的 .find() 方法。contains 也只能判断是否包含某个单个元素,也就是某个 u8,不能判断是否包含一段子切片。
要判断一个字节切片里是否包含另一个字节切片,可以用 windows()。haystack.windows(N) 会生成所有长度为 N 的连续子切片。只要把 N 设成 needle 的长度,再逐个比较,就能判断 needle 是否出现过:
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
为了让调用更灵活,可以把它改成泛型,接收任何可以转成字节切片的类型:
fn contains<H, N>(haystack: H, needle: N) -> bool
where
H: AsRef<[u8]>,
N: AsRef<[u8]>,
{
let haystack = haystack.as_ref();
let needle = needle.as_ref();
haystack
.windows(needle.len())
.any(|window| window == needle)
}
这样既可以传 &packet[..],也可以传字符串字面量 "abcdefghijkl",因为字符串也能通过 AsRef<[u8]> 看作字节序列。
把它放进监听循环里:
iface.loop_infinite_dyn(&mut |packet| {
let now = start.elapsed();
if contains(&packet[..], "abcdefghijkl") {
println!("{:?} | probably an ICMP packet", now);
} else {
println!("{:?} | probably *not* an ICMP packet", now);
}
})?;
运行时,如果后台确实在不断 ping 8.8.8.8,输出里会间歇出现 probably an ICMP packet。这不是严格协议解析,只是一个启发式验证:某些抓到的包确实包含了 ping payload,因此我们大概率已经能看到 ICMP Echo Request 或 Echo Reply 相关流量。
接下来把 packet 处理逻辑从 main 中拆出来:
use rawsock::BorrowedPacket;
use std::time::{Duration, Instant};
fn main() -> Result<(), Error> {
let lib = open_best_library()?;
let iface_name = format!(
r#"\Device\NPF_{}"#,
netinfo::default_nic_guid()?
);
let iface = lib.open_interface(&iface_name)?;
println!("Listening for packets...");
let start = Instant::now();
iface.loop_infinite_dyn(&mut |packet| {
if !contains(&packet[..], "abcdefghijkl") {
return;
}
process_packet(start.elapsed(), packet);
})?;
Ok(())
}
fn process_packet(now: Duration, packet: &BorrowedPacket) {
println!("{:?} | probably an ICMP packet", now);
}
现在主循环只负责过滤和调度,后面的真正解析放进 process_packet。
三、我们抓到的是 Ethernet 帧,还是 IP 包
现在知道某些包里可能有 ICMP payload,但还不知道 rawsock 交给我们的字节到底从哪一层开始。它可能是完整的 Ethernet frame,也可能从 IP packet 开始。这取决于底层库和平台。
如果是 Ethernet 帧,头部格式非常固定:
0..6 目标 MAC 地址
6..12 源 MAC 地址
12..14 EtherType
14.. payload
其中 EtherType 用来说明后面的 payload 是什么协议。IPv4 的 EtherType 是 0x0800。因此,只要读取 packet 第 12、13 两个字节,并把它们解释成 16 位整数,就可以判断它是不是 Ethernet 帧以及后面是否是 IPv4。
最粗暴的写法是直接把第 12 个字节的位置当成 u16 指针解引用:
use std::mem::transmute;
fn process_packet(now: Duration, packet: &BorrowedPacket) {
let ether_type: u16 = unsafe {
let u16_ptr: *const u16 = transmute(&packet[12]);
*u16_ptr
};
println!("{:?} | ether_type = 0x{:04x}", now, ether_type);
}
运行后会看到:
ether_type = 0x0008
这很接近,但不对。IPv4 应该是 0x0800,现在却是 0x0008。两个字节被交换了。这不是抓包错了,而是大小端问题。
Ethernet 里的多字节整数使用网络字节序,也就是大端。Intel 这类常见 x86/x64 处理器使用小端。直接把内存中的两个字节 08 00 当成本机 u16 读,就会得到 0x0008。这说明不能随便用 transmute 读网络协议字段,除非明确处理字节序。
四、用 u16::from_be_bytes 正确读取大端整数
可以手动读取两个字节,再移位组合:
fn process_packet(now: Duration, packet: &BorrowedPacket) {
let a = packet[12] as u16;
let b = packet[13] as u16;
let ether_type = (a << 8) + b;
println!("{:?} | ether_type = 0x{:04x}", now, ether_type);
}
这能得到正确的 0x0800,但手写 bit 操作容易出错,也不够表达语义。Rust 标准库已经提供了更清楚的办法:u16::from_be_bytes。它接收一个固定长度数组 [u8; 2],按照 big-endian 解释成 u16。
问题是 packet 切片里取出来的是 &[u8],不是 [u8; 2]。可以先创建一个两字节数组,然后用 copy_from_slice 把 packet[12..14] 拷进去:
fn process_packet(now: Duration, packet: &BorrowedPacket) {
let ether_type = {
let mut ether_type = [0u8; 2];
ether_type.copy_from_slice(&packet[12..14]);
u16::from_be_bytes(ether_type)
};
println!("{:?} | ether_type = 0x{:04x}", now, ether_type);
}
这样没有 unsafe,语义也更明确:从 packet 的第 12、13 字节读取一个大端 u16。需要注意,copy_from_slice 要求源切片长度和目标数组长度完全一致。如果传 &packet[12..],长度通常大于 2,它不会自动只取前两个字节,而是直接 panic。因此必须明确传 &packet[12..14]。
这一步说明了两个重要点。第一,网络协议字段要按协议规定的字节序读取,不能按本机字节序偷懒。第二,Rust 标准库已经能很好地表达这种需求,很多时候不需要引入额外 crate,也不需要 unsafe。
五、解析目标 MAC 和源 MAC
既然 EtherType 确认是 0x0800,说明 rawsock 交给我们的确实很像 Ethernet 帧。接下来可以把目标 MAC 和源 MAC 也解析出来。
先新增一个 ethernet 模块:
// src/main.rs
mod ethernet;
在 src/ethernet.rs 中定义 Ethernet 地址类型:
use std::fmt;
#[derive(PartialEq, Eq, Clone, Copy)]
pub struct Addr([u8; 6]);
impl fmt::Display for Addr {
fn fmt(&self, w: &mut fmt::Formatter) -> fmt::Result {
let [a, b, c, d, e, f] = self.0;
write!(
w,
"{:02X}-{:02X}-{:02X}-{:02X}-{:02X}-{:02X}",
a, b, c, d, e, f
)
}
}
impl fmt::Debug for Addr {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
MAC 地址有时写成 12:34:56:78:9A:BC,也常写成 12-34-56-78-9A-BC。这里使用连字符格式,一方面 Windows 的 ipconfig /all 也常这么显示,另一方面它不会和 IPv6 地址的冒号格式混淆。
再给 Addr 加一个从切片构造的方法:
impl Addr {
pub fn new(slice: &[u8]) -> Self {
let mut res = Self([0u8; 6]);
res.0.copy_from_slice(&slice[..6]);
res
}
}
这里同样要注意:如果传入切片不足 6 字节,slice[..6] 会 panic。这暂时可以接受,因为后面会用更系统的 parser 处理错误。当前只是快速确认抓到的包结构是否合理。
在 process_packet 中读取三段字段:
fn process_packet(now: Duration, packet: &BorrowedPacket) {
let read_u16 = |slice: &[u8]| {
let mut res = [0u8; 2];
res.copy_from_slice(&slice[..2]);
u16::from_be_bytes(res)
};
let dst = ethernet::Addr::new(&packet[0..]);
let src = ethernet::Addr::new(&packet[6..]);
let ether_type = read_u16(&packet[12..]);
println!(
"{:?} | dst {} | src {} | typ 0x{:04x}",
now, dst, src, ether_type
);
}
运行后能看到成对出现的包:
dst 14-0C-76-6A-71-BD | src F4-D1-08-0B-7E-BC | typ 0x0800
dst F4-D1-08-0B-7E-BC | src 14-0C-76-6A-71-BD | typ 0x0800
这非常符合 ping 的流量特征:一条从本机网卡发往路由器或网关,另一条从对方返回本机。MAC 地址也可以用厂商数据库验证,比如一个是 Intel 网卡,一个是 ISP 路由器设备。这进一步确认我们正在解析的确实是 Ethernet 帧,而且其中包含 IPv4 流量。
六、第一次代码审查:手写解析不够清楚
现在已经能读出目标 MAC、源 MAC 和 EtherType。功能上是成功的,但代码还不够满意。当前解析逻辑非常命令式,而且很多地方靠注释和人工记忆维持正确性。
例如:
let read_u16 = |slice: &[u8]| {
let mut res = [0u8; 2];
res.copy_from_slice(&slice[..2]);
u16::from_be_bytes(res)
};
let dst = ethernet::Addr::new(&packet[0..]);
let src = ethernet::Addr::new(&packet[6..]);
let ether_type = read_u16(&packet[12..]);
这里有几个明显问题。read_u16 是闭包,但它其实是通用工具函数,不应该藏在 process_packet 内部。Addr::new(&packet[0..]) 只从前 6 字节读地址,但调用点并不直观。Addr::new(&packet[6..]) 看起来像从第 6 字节开始读,但到底读 6 字节还是更多,要跳进函数才知道。read_u16(&packet[12..]) 也是同样问题,它接收任意长度切片,但实际上只读前两个字节。
更重要的是,错误处理完全靠 panic。如果 packet 长度不足 14 字节,任何一个切片索引或 copy_from_slice 都可能崩溃。对于实验阶段没问题,但如果要构建协议解析器,应该让解析函数返回错误,而不是直接把抓包程序干掉。
在引入新解析方案之前,先验证一个小性能问题:copy_from_slice 加 u16::from_be_bytes 会不会生成很啰嗦的代码?
七、from_be_bytes 的性能验证
为了验证 read_u16 编译后是否足够高效,可以写一个单独小项目:
#[inline(never)]
pub extern "C" fn read_u16(slice: &[u8]) -> u16 {
let mut res = [0u8; 2];
res.copy_from_slice(&slice[..2]);
u16::from_be_bytes(res)
}
fn main() {
println!("{}", read_u16(&[0x12, 0x34]));
println!("{}", read_u16(&[0x56, 0x78]));
}
release 模式下开启调试符号,然后用 x64dbg 查看汇编。结果非常短,大致是:
movzx eax, word ptr ds:[rcx]
rol ax, 8
ret
这段汇编说明编译器优化得很好。第一条从内存读取两个字节并零扩展到寄存器,第二条把 16 位寄存器旋转 8 位,正好完成字节交换。返回值放在 RAX/EAX 相关寄存器里,符合 Microsoft x64 调用约定。
这说明高层写法并没有牺牲性能。copy_from_slice 和 from_be_bytes 在优化构建下可以生成很短的机器码。它既比手写 bit 操作更清楚,也不需要 unsafe,还能得到很好的 codegen。
八、先把 Ethernet 帧建模成结构体
在引入 nom 之前,先把解析结果整理成一个结构体。一个最小 Ethernet 帧头部包含三个字段:目标 MAC、源 MAC 和 EtherType。
可以定义:
use custom_debug_derive::*;
#[derive(CustomDebug)]
pub struct Frame {
pub dst: Addr,
pub src: Addr,
#[debug(format = "0x{:04x}")]
pub ether_type: u16,
}
custom_debug_derive 用来控制 ether_type 的调试输出格式,让它显示成 0x0800 而不是十进制整数。
然后给 Frame 实现一个手写解析函数:
impl Frame {
pub fn parse(i: &[u8]) -> Self {
let read_u16 = |slice: &[u8]| {
let mut res = [0u8; 2];
res.copy_from_slice(&slice[..2]);
u16::from_be_bytes(res)
};
Self {
dst: Addr::new(&i[0..]),
src: Addr::new(&i[6..]),
ether_type: read_u16(&i[12..]),
}
}
}
process_packet 变成:
fn process_packet(now: Duration, packet: &BorrowedPacket) {
let frame = ethernet::Frame::parse(packet);
println!("{:?} | {:?}", now, frame);
}
输出就更结构化了:
Frame {
dst: 14-0C-76-6A-71-BD,
src: F4-D1-08-0B-7E-BC,
ether_type: 0x0800
}
这一步已经比散落在 process_packet 中的切片读取好一些,但 Frame::parse 仍然是手写切片索引,错误处理仍然靠 panic。接下来引入 nom,让解析逻辑变成 parser 的组合。
九、引入 nom:用 parser combinator 解析二进制协议
nom 是 Rust 里非常常用的 parser combinator 库。它既能解析文本格式,也能解析二进制格式。parser combinator 的核心思想是:parser 是一个函数,它接收输入,返回解析结果;复杂 parser 可以由多个小 parser 组合而成。
添加依赖:
cargo add nom
这里使用的是 nom 5。nom 有 complete 和 streaming 两类 parser。当前 rawsock 已经给我们一整个 packet,也就是完整输入,不是在网络流中边读边解析。因此使用 nom::number::complete::be_u16,而不是 streaming 版本。
be_u16 的作用是从输入开头读取一个 big-endian 的 u16。它返回的是 IResult,不是普通 Result。原因在于 parser 成功时不仅要返回解析出来的值,还要返回“剩余还没消费的输入”。因此成功结果本质上类似:
Ok((remaining_input, parsed_value))
先把原来的 read_u16 替换成 be_u16:
use nom::number::complete::be_u16;
impl Frame {
pub fn parse(i: &[u8]) -> Self {
let (_, ether_type) = be_u16::<()>(&i[12..]).unwrap();
Self {
dst: Addr::new(&i[0..]),
src: Addr::new(&i[6..]),
ether_type,
}
}
}
这能工作,但还没有发挥 nom 的意义。这里只是用 be_u16 替代了一个手写函数,仍然手动切片 &i[12..],仍然 .unwrap() 忽略错误,也没有组合 parser。
要真正使用 nom,需要给 MAC 地址也写 parser。
十、实现 MAC 地址 parser
MAC 地址就是连续 6 个字节。nom 里可以用 take(6_usize) 取出 6 字节,再用 map 把这段字节转换成 ethernet::Addr:
use nom::{
bytes::complete::take,
combinator::map,
error::ParseError,
IResult,
};
impl Addr {
pub fn parse<'a, E>(i: &'a [u8]) -> IResult<&'a [u8], Self, E>
where
E: ParseError<&'a [u8]>,
{
map(take(6_usize), Self::new)(i)
}
}
这里看起来类型很多,但逻辑很简单。Addr::parse 接收一个字节切片,返回 nom 的 IResult。take(6_usize) 是一个 parser,它从输入中取出 6 字节,返回一个切片。map(parser, f) 会运行 parser,如果成功,就把 parser 的输出交给函数 f 转换。这里 f 是 Self::new,所以最终输出是 ethernet::Addr。
也可以用 6 个 be_u8 加 tuple 来写:
use nom::{
combinator::map,
number::complete::be_u8,
sequence::tuple,
};
impl Addr {
pub fn parse<'a, E>(i: &'a [u8]) -> IResult<&'a [u8], Self, E>
where
E: ParseError<&'a [u8]>,
{
map(
tuple((be_u8, be_u8, be_u8, be_u8, be_u8, be_u8)),
|(a, b, c, d, e, f)| Self([a, b, c, d, e, f]),
)(i)
}
}
但第一种 take(6) 更符合 MAC 地址的语义:直接取 6 字节,构造地址。一个字节本身不存在大小端问题,be_u8 和 le_u8 没本质区别,因此这里没必要把 6 个字节拆得太细。
有了 Addr::parse 后,当然可以继续“偷懒”:
impl Frame {
pub fn parse(i: &[u8]) -> Self {
let (_, dst) = Addr::parse::<()>(&i[0..]).unwrap();
let (_, src) = Addr::parse::<()>(&i[6..]).unwrap();
let (_, ether_type) = be_u16::<()>(&i[12..]).unwrap();
Self {
dst,
src,
ether_type,
}
}
}
但这仍然没有组合 parser。真正要做的是:从输入开头依次解析目标 MAC、源 MAC、EtherType。
十一、组合 parser 解析完整 Ethernet 帧
nom 的 tuple 可以把多个 parser 串起来。tuple((A, B, C)) 会先运行 A,再用 A 剩余的输入运行 B,再用 B 剩余的输入运行 C。全部成功后,返回 (a, b, c)。
因此,Ethernet 帧头部可以写成:
use nom::{
combinator::map,
number::complete::be_u16,
sequence::tuple,
};
impl Frame {
pub fn parse<'a, E>(i: &'a [u8]) -> IResult<&'a [u8], Self, E>
where
E: ParseError<&'a [u8]>,
{
map(
tuple((Addr::parse, Addr::parse, be_u16)),
|(dst, src, ether_type)| Self {
dst,
src,
ether_type,
},
)(i)
}
}
这才是 parser combinator 的风格:Frame::parse 不再手动写 i[0..]、i[6..]、i[12..],而是声明“一个 Ethernet frame header 由 MAC 地址、MAC 地址、big-endian u16 组成”。每个 parser 会自动消费自己负责的输入,剩余输入继续传给下一个 parser。
这种代码更接近协议格式本身,也更容易扩展。以后解析 IPv4 时,也可以用类似方式组合 version/IHL、DSCP/ECN、total length、identification、flags/fragment offset、TTL、protocol、checksum、source、destination 等字段。
十二、用 parse 模块简化类型
前面的 parser 签名有点啰嗦,每次都要写生命周期、IResult、错误泛型和 where E: ParseError。可以新增一个 parse 模块,把常用类型统一起来:
// src/main.rs
mod parse;
// src/parse.rs
pub type Input<'a> = &'a [u8];
pub type Result<'a, T> = nom::IResult<Input<'a>, T, ()>;
然后 ethernet.rs 可以写得更干净:
impl Addr {
pub fn parse(i: parse::Input) -> parse::Result<Self> {
map(take(6_usize), Self::new)(i)
}
}
impl Frame {
pub fn parse(i: parse::Input) -> parse::Result<Self> {
map(
tuple((Addr::parse, Addr::parse, be_u16)),
|(dst, src, ether_type)| Self {
dst,
src,
ether_type,
},
)(i)
}
}
process_packet 也要处理解析错误:
fn process_packet(now: Duration, packet: &BorrowedPacket) {
match ethernet::Frame::parse(packet) {
Ok((_remaining, frame)) => {
println!("{:?} | {:?}", now, frame);
}
Err(e) => {
println!("{:?} | could not parse ethernet frame: {:?}", now, e);
}
}
}
现在解析函数不再 panic,而是返回 IResult。不过当前错误类型是 (),也就是空元组。短包解析失败时,只能看到 Error(()),几乎没有信息。下一步需要改进错误类型。
十三、从空错误到可读错误
先把错误类型改成 nom 内置支持的一种形式:
use nom::error::ErrorKind as NomErrorKind;
pub type Input<'a> = &'a [u8];
pub type Result<'a, T> =
nom::IResult<Input<'a>, T, (Input<'a>, NomErrorKind)>;
这样,如果故意传入一个很短的 packet,例如只取前两个字节:
let incomplete_packet = &packet[..2];
解析失败时能看到类似:
Error(([20, 12], Eof))
这比 Error(()) 好一些。它告诉我们剩余输入是什么,以及错误种类是 Eof,也就是输入提前结束。但这个格式还不够清楚。接下来可以自定义解析错误类型。
在 parse.rs 中定义:
use nom::error::{
ErrorKind as NomErrorKind,
ParseError as NomParseError,
};
pub type Input<'a> = &'a [u8];
pub type Result<'a, T> =
nom::IResult<Input<'a>, T, Error<Input<'a>>>;
#[derive(Debug)]
pub struct Error<I> {
pub errors: Vec<(I, NomErrorKind)>,
}
impl<I> NomParseError<I> for Error<I> {
fn from_error_kind(input: I, kind: NomErrorKind) -> Self {
let errors = vec![(input, kind)];
Self { errors }
}
fn append(input: I, kind: NomErrorKind, mut other: Self) -> Self {
other.errors.push((input, kind));
other
}
}
这里实现了 nom 要求的 ParseError trait。from_error_kind 创建一个新的错误,append 把新的错误信息追加到已有错误中。之所以用 Vec 保存错误,是为了后面支持更多上下文信息。
现在解析失败会显示:
Error(Error { errors: [([20, 12], Eof)] })
虽然还不漂亮,但已经有了自己的错误结构,后面可以继续增强。
十四、用 context 添加解析上下文
nom 提供了 context combinator,可以给解析错误附加上下文。比如在解析 MAC 地址时包一层 "MAC address",在解析完整帧时包一层 "Ethernet frame":
use nom::{
bytes::complete::take,
combinator::map,
error::context,
number::complete::be_u16,
sequence::tuple,
};
impl Addr {
pub fn parse(i: parse::Input) -> parse::Result<Self> {
context(
"MAC address",
map(take(6_usize), Self::new),
)(i)
}
}
impl Frame {
pub fn parse(i: parse::Input) -> parse::Result<Self> {
context(
"Ethernet frame",
map(
tuple((
Addr::parse,
Addr::parse,
context("EtherType", be_u16),
)),
|(dst, src, ether_type)| Self {
dst,
src,
ether_type,
},
),
)(i)
}
}
但如果直接运行,输出可能没有变化。原因是 ParseError trait 里的 add_context 有默认实现,而默认实现只是把上下文丢掉:
fn add_context(_input: I, _ctx: &'static str, other: Self) -> Self {
other
}
要真正保留上下文,需要把错误种类从单纯的 NomErrorKind 扩展成自己的 enum:
#[derive(Debug)]
pub enum ErrorKind {
Nom(NomErrorKind),
Context(&'static str),
}
#[derive(Debug)]
pub struct Error<I> {
pub errors: Vec<(I, ErrorKind)>,
}
然后改 ParseError 实现:
impl<I> NomParseError<I> for Error<I> {
fn from_error_kind(input: I, kind: NomErrorKind) -> Self {
let errors = vec![(input, ErrorKind::Nom(kind))];
Self { errors }
}
fn append(input: I, kind: NomErrorKind, mut other: Self) -> Self {
other.errors.push((input, ErrorKind::Nom(kind)));
other
}
fn add_context(input: I, ctx: &'static str, mut other: Self) -> Self {
other.errors.push((input, ErrorKind::Context(ctx)));
other
}
}
这时短包解析失败就能看到更完整的错误链:
Error {
errors: [
([20, 12], Nom(Eof)),
([20, 12], Context("MAC address")),
([20, 12], Context("Ethernet frame"))
]
}
这个错误说明:底层错误是 Eof,也就是输入不够;它发生在解析 MAC address 时;而 MAC address 又处在 Ethernet frame 的解析上下文里。这比单独的 Eof 有用得多。
十五、美化解析错误输出
当前错误已经有了上下文,但 Debug 输出还是不够适合人读。可以给 Error<&[u8]> 实现自己的 Debug,用更清楚的方式显示错误栈和相关输入字节。
最初可以用 hex-slice 之类的 crate 打印十六进制,但它的格式不一定满足需求。更理想的输出是:每一层上下文单独一行,下面显示一段输入的十六进制,并用波浪线标出这层错误对应的切片位置。
实现思路是:错误里保存的每个 input 都是原始输入的某个子切片。nom 提供了 Offset trait,可以计算一个子切片相对于父切片的偏移。这样就能在父输入中定位子输入的位置。
自定义格式大概会输出类似:
/!\ ersatz parsing error
...in Ethernet frame
14 0C 76 6A 71 BD F4 D1 08 0B 7E BC 08
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
...in EtherType
14 0C 76 6A 71 BD F4 D1 08 0B 7E BC 08
~~
nom error Eof
14 0C 76 6A 71 BD F4 D1 08 0B 7E BC 08
~~
这种输出能告诉你:整个 Ethernet frame 的解析从这段输入开始,最后失败发生在 EtherType 字段,因为这里只剩一个字节,不够读取 u16。相比 Error(([8], Eof)),这种格式对调试二进制协议解析更有帮助。
这部分代码本身不短,但核心逻辑是:记录最外层输入,遍历错误链,利用 Offset 找到每一层子切片在父输入中的位置,再按十六进制打印一小段上下文,并用 ~ 标出当前错误区域。对于协议解析器来说,好的错误信息非常重要,因为二进制格式不像文本那样一眼能看出字段边界。
十六、处理真实错误:未知 EtherType
前面的错误都是人为制造的短包。真实世界里会遇到另一类错误:包是完整的,但不是我们当前支持的协议。
目前 Ethernet 帧的 ether_type 还是裸 u16。但实际上我们暂时只关心 IPv4,也就是 0x0800。可以把它建模成 enum:
use derive_try_from_primitive::*;
#[derive(Debug, TryFromPrimitive)]
#[repr(u16)]
pub enum EtherType {
IPv4 = 0x0800,
}
derive-try-from-primitive 可以自动生成从 u16 到 enum 的转换逻辑。然后把 Frame 改成:
#[derive(Debug)]
pub struct Frame {
pub dst: Addr,
pub src: Addr,
pub ether_type: EtherType,
}
一开始可能会这么写解析器:
impl EtherType {
pub fn parse(i: parse::Input) -> parse::Result<Self> {
context(
"EtherType",
map(be_u16, |x| EtherType::try_from(x).unwrap()),
)(i)
}
}
这个版本在只抓 IPv4 时能工作,但一旦抓到 IPv6,就会崩溃。IPv6 的 EtherType 是 0x86DD,EtherType::try_from(0x86DD) 会返回 None,随后 .unwrap() panic,整个抓包程序就被干掉了。
抓包程序不应该因为看到一个暂时不支持的协议就崩溃。未知 EtherType 应该是一个解析错误,而且这个错误应该能清楚显示具体值。
先给自定义错误增加一种类型:
#[derive(Debug)]
pub enum ErrorKind {
Nom(NomErrorKind),
Context(&'static str),
Custom(String),
}
impl<I> Error<I> {
pub fn custom(input: I, msg: String) -> Self {
Self {
errors: vec![(input, ErrorKind::Custom(msg))],
}
}
}
然后改 EtherType::parse。这里不能再用纯 map(... unwrap ...),而是先调用 be_u16,拿到整数后手动判断:
impl EtherType {
pub fn parse(i: parse::Input) -> parse::Result<Self> {
let original_i = i;
let (i, x) = context("EtherType", be_u16)(i)?;
match EtherType::try_from(x) {
Some(typ) => Ok((i, typ)),
None => {
let msg = format!("unknown EtherType 0x{:04X}", x);
use nom::Offset;
let err_slice = &original_i[..original_i.offset(i)];
Err(nom::Err::Error(parse::Error::custom(
err_slice,
msg,
)))
}
}
}
}
这里 original_i 是进入 EtherType parser 前的输入,i 是读取两个字节之后剩下的输入。通过 original_i.offset(i) 可以知道 parser 消费了多少字节。err_slice 就是导致错误的那两个字节。最终错误输出会类似:
/!\ ersatz parsing error
...in Ethernet frame
14 0C 76 6A 71 BD F4 D1 08 0B 7E BC 86 DD 60 00 ...
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
unknown EtherType 0x86DD
14 0C 76 6A 71 BD F4 D1 08 0B 7E BC 86 DD 60 00 ...
~~~~~
这就是一个真实世界里的解析错误:程序抓到了 IPv6 帧,但当前只支持 IPv4,所以返回“未知 EtherType 0x86DD”,并标出对应字节。它不再 panic,也不会中断整个 sniffer。
十七、nom 不只是文本解析库
到这里,nom 的价值已经很清楚。它不只是用来解析字符串、配置文件或文本格式,也很适合解析二进制协议。
二进制协议解析的关键问题包括:按顺序消费输入、读取固定长度字段、读取大小端整数、组合多个小 parser、保留剩余输入、处理输入不足、给错误加上下文、在遇到未知枚举值时返回自定义错误。nom 正好提供了这些基础设施。
最终的 Ethernet parser 可以这样理解:
impl Frame {
pub fn parse(i: parse::Input) -> parse::Result<Self> {
context(
"Ethernet frame",
map(
tuple((
Addr::parse,
Addr::parse,
EtherType::parse,
)),
|(dst, src, ether_type)| Self {
dst,
src,
ether_type,
},
),
)(i)
}
}
这段代码几乎就是 Ethernet 头部格式的声明:先解析目标 MAC,再解析源 MAC,再解析 EtherType。与手写 i[0..]、i[6..]、i[12..] 相比,它更能表达协议结构,也更容易把错误上下文挂在具体字段上。
十八、检查 nom 生成的机器码
引入 parser combinator 后,一个自然担心是:抽象层多了,会不会很慢?会不会生成大量不必要函数调用?为了验证这一点,可以做 release 构建,然后用 x64dbg 看生成的汇编。
初次 release 构建后,可执行文件大小大约几百 KiB。通过符号找到 ethernet::Frame::parse,能看到它调用了几个子函数,其中一些可能来自 nom 的 trait 方法。继续观察可以发现:读取 big-endian u16 的部分仍然和之前手写 read_u16 很像,核心仍然是读取两个字节并交换顺序。Rust enum 也没有以臃肿形式保留,而是被优化成非常直接的分支和比较。
如果开启 Link-Time Optimization:
[profile.release]
lto = true
再构建一次,很多跨 crate 边界会被进一步内联。原来能找到的 Frame::parse 符号可能消失,因为它已经被内联到调用处。原先一些 nom trait 方法调用也可能被优化掉。最终在汇编里仍然能找到类似 movzx 和 rol 的高效字节读取逻辑,也能看到处理 unknown EtherType 错误的分支。
这说明高层 parser combinator 并不必然意味着低效。只要写法清楚、构建配置合理,rustc、LLVM 和 MSVC 后端可以把很多组合器内联和优化掉。更重要的是,这些抽象让源码更接近协议结构,同时保留了接近手写解析的性能潜力。
十九、这一篇当前完成了什么
到这里,ersatz 已经可以打开默认网卡,监听原始流量,并把抓到的字节解析成 Ethernet frame。它能读取目标 MAC、源 MAC 和 EtherType。对于 IPv4 帧,能正常显示:
Frame {
dst: 14-0C-76-6A-71-BD,
src: F4-D1-08-0B-7E-BC,
ether_type: IPv4
}
对于短包,能返回带上下文的 Eof 错误。对于暂时不支持的 EtherType,比如 IPv6 的 0x86DD,能返回自定义错误,并用十六进制输出标出错误字段。
这不是一个完整协议栈,但已经是重要的一步。前面只是能“抓到 bytes”,现在已经能把最外层 Ethernet 头部解析成结构体。后续可以沿着同样模式继续往里走:如果 ether_type == IPv4,就把剩余 payload 交给 IPv4 parser;如果 IPv4 协议号是 ICMP,就把 IPv4 payload 交给 ICMP parser。最终,自制 ping 会从“调用 Windows API 发 ICMP”走向“自己理解并构造 Ethernet/IPv4/ICMP”。
二十、这一篇真正学到的东西
第一,抓到数据只是开始,理解数据从哪一层开始更重要。通过读取第 12、13 字节的 EtherType,可以确认 rawsock 给到的是 Ethernet 帧,并且 payload 是 IPv4。
第二,不能用 transmute 随便读网络协议字段。网络协议通常使用大端字节序,而常见 x86/x64 主机是小端。直接把内存解释成 u16 会得到反过来的值。u16::from_be_bytes 语义清楚、安全、优化效果也很好。
第三,MAC 地址值得建模成类型。ethernet::Addr([u8; 6]) 比裸 [u8; 6] 更有语义,也能实现更友好的显示格式。
第四,手写切片解析能快速验证思路,但不适合长期维护。i[0..]、i[6..]、i[12..] 这种代码需要读者手动对应协议字段,错误处理也容易退化成 panic。
第五,nom 的 parser combinator 很适合二进制协议解析。take(6)、be_u16、map、tuple、context 组合起来,可以把 Ethernet frame 的结构写成声明式解析器。
第六,错误处理不能偷懒。空错误 () 没有调试价值,内置 (Input, ErrorKind) 稍好,但自定义错误类型可以同时记录 nom 错误、上下文和自定义消息。二进制协议解析尤其需要好错误信息,否则很难知道到底是哪几个字节出了问题。
第七,真实网络里会出现各种协议。只支持 IPv4 时,遇到 IPv6 不应该 panic,而应该返回“未知 EtherType”这样的可读错误。sniffer 应该尽量健壮,不能因为一个暂时不支持的帧就崩溃。
第八,高层抽象不一定慢。nom 生成的解析代码在 release 和 LTO 下可以被很好地内联和优化,核心字节读取逻辑仍然非常紧凑。先写清楚,再用工具确认性能,比一开始就手写不安全解析更稳妥。
二十一、总结
这一篇从监听 rawsock packet 开始,逐步把原始字节解析成 Ethernet 帧。最开始只是打印包长度,然后用 windows() 搜索 ping 默认 payload,确认抓到的流量里确实有 ICMP Echo 相关数据。接着通过读取 Ethernet 头部的 EtherType,确认 rawsock 给到的是 Ethernet frame,并且其中包含 IPv4 payload。
第一次读取 EtherType 时直接用 transmute 得到了 0x0008,由此暴露出大小端问题。网络协议使用大端,而常见 Intel 处理器是小端,所以需要用 u16::from_be_bytes 正确读取。这个写法没有 unsafe,语义明确,release 编译后也能优化成非常短的汇编。
随后新增 ethernet::Addr 表示 MAC 地址,并从帧头中解析目标 MAC、源 MAC 和 EtherType。手写解析跑通后,又对代码做了一次审查:手写切片索引不够清楚,错误处理靠 panic,也不容易继续扩展。于是引入 nom,先用 be_u16 替代手写读取,再为 MAC 地址实现 parser,最后用 tuple((Addr::parse, Addr::parse, be_u16)) 组合出 Ethernet frame parser。
为了让解析代码更可维护,又抽出 parse 模块,统一输入和结果类型。错误处理从最开始的 (),逐步演进为自定义 parse::Error,实现 nom::error::ParseError,支持 context,并能把错误上下文、nom 错误种类和自定义错误消息都保留下来。为了让二进制错误更可读,还实现了漂亮的十六进制输出,用波浪线标出解析失败的字节范围。
最后,把 EtherType 从裸 u16 改成 enum,只支持 IPv4 = 0x0800。遇到 IPv6 的 0x86DD 时,不再因为 .unwrap() 崩溃,而是返回带位置标记的“unknown EtherType”错误。release 构建和 x64dbg 检查表明,nom 这类 parser combinator 抽象在优化后并不会生成离谱代码,LTO 还能进一步消除跨 crate 开销。
这篇完成的是协议栈最外层的一步:消费 Ethernet 帧。后面要继续沿着同样方法解析 IPv4,再解析 ICMP,最终才能真正自己构造和发送 ping 所需的数据包。到这里为止,项目已经不再只是调用系统 API,而是在逐步拥有自己的网络协议解析能力。

461

被折叠的 条评论
为什么被折叠?



