用 Rust 真正发出 Ping:FFI 类型、newtype 与 MaybeUninit

本文是对 FFI-safe types in Rust, newtypes and MaybeUninit 的整理与翻译。

内容结构概览

  1. 从调用 ICMP API 开始 :上一节已经学会动态加载 DLL,这一节正式调用 IPHLPAPI.dll 中的 IcmpSendEcho
  2. 理解 IcmpSendEcho 的 C 函数签名 :把 DWORDWORDHANDLELPVOIDIPAddr 等 Win32 类型翻译成 Rust 类型。
  3. 用 newtype 表示 IPv4 地址 :用 struct IPAddr([u8; 4]) 表示地址,并自定义 Debug 输出为 8.8.8.8
  4. 为什么 8.8.8.8 会变成整数 134744072:底层内存布局决定了 Win32 API 看到的是一个 32 位整数。
  5. 处理 IP_OPTION_INFORMATION:理解 TTL、TOS、Flags、OptionsSize、OptionsData,以及 64 位 Windows 上为什么用 32 位指针布局。
  6. #[repr(C)] 的必要性:Rust 默认结构体布局不保证和 C 一致,FFI 边界必须明确指定 C 布局。
  7. 对齐、padding 与结构体大小:结构体字段不是简单相加,内存对齐会影响布局。
  8. 定义 IcmpSendEchoIcmpCreateFile 函数指针类型:把 Win32 API 映射到 Rust FFI 类型。
  9. 第一次真正发出 ICMP Echo :调用 IcmpCreateFile 拿 handle,再调用 IcmpSendEcho ping 8.8.8.8
  10. 返回值不是传统 Win32 语义IcmpSendEcho 返回的是写入 reply buffer 的 ICMP_ECHO_REPLY 数量,返回 1 表示成功。
  11. 用 hex dump 检查 reply buffer:发现响应缓冲区里包含结构体、额外字节和原始请求数据。
  12. 建模 ICMP_ECHO_REPLY :用 Rust 结构体对应 Win32 的 ICMP_ECHO_REPLY
  13. MaybeUninit 的尝试与失败 :只分配一个 IcmpEchoReply 不够,因为 reply buffer 还要容纳请求数据和额外 8 字节。
  14. 正确分配 reply buffer :缓冲区大小应为 ICMP_ECHO_REPLY 结构体大小 + 8 字节 + 请求数据长度。
  15. 从字节缓冲区解释结构体与数据:用指针转换和 slice 读取返回结构体与原始响应数据。
  16. 总结:这一篇真正完成了自己的 ping,但代码仍然处在 unsafe、动态加载和手工解析缓冲区阶段,后续还需要重构和增强。

前两篇已经做了两件事。第一篇先把计算机网络的基本脉络理清楚:为什么要连接计算机,为什么有以太网、MAC 地址、IP、DNS、DHCP,以及 ping 为什么能作为理解网络协议栈的入口。第二篇则回到 Windows 平台,观察系统自带的 ping.exe 到底调用了什么 API,并用 Rust 打通了动态加载 DLL、通过 GetProcAddress 获取函数地址、再用 transmute 转成函数指针这一整条链路。

到了这一篇,终于可以真正发送 ICMP Echo 请求了。目标不是再弹一个 MessageBoxA,而是调用 Windows 提供的 ICMP API,向 8.8.8.8 发出一次请求,并读取返回结果。看起来只是把上一节的 USER32.dll 换成 IPHLPAPI.dll,把 MessageBoxA 换成 IcmpSendEcho,但实际复杂度会高很多。因为 MessageBoxA 的参数基本只是几个指针和整数,而 IcmpSendEcho 涉及 Win32 类型、IPv4 地址表示、IP 选项结构体、输出缓冲区、C 布局、Rust 结构体布局、未初始化内存和返回数据解析。

这一篇真正重要的地方,不只是"ping 通了"。更重要的是,它展示了 Rust 和 C API 对接时必须认真处理的一系列边界问题:什么类型在 FFI 边界是安全的,Rust 结构体怎样保证和 C 结构体内存布局一致,为什么不能随便把一个 Rust 结构体传给 Win32 API,为什么输出参数不能直接用普通变量接收,以及为什么一个看似简单的 reply buffer 背后其实藏着结构体、原始字节和额外空间要求。


一、从 IcmpSendEcho 开始

上一节已经通过 dumpbin 和 API Monitor 找到 Windows 自带 ping.exe 的调用线索。它使用的是 IPHLPAPI.dll 中的 ICMP 相关函数,其中比较完整的是 IcmpSendEcho2Ex。不过,在自己实现第一个版本时,不一定需要一上来就使用最复杂的版本。Windows 还提供了一个更简单的同步函数:IcmpSendEcho

IcmpSendEcho 的 C 声明大致如下:

c 复制代码
IPHLPAPI_DLL_LINKAGE DWORD IcmpSendEcho(
  HANDLE                 IcmpHandle,
  IPAddr                 DestinationAddress,
  LPVOID                 RequestData,
  WORD                   RequestSize,
  PIP_OPTION_INFORMATION RequestOptions,
  LPVOID                 ReplyBuffer,
  DWORD                  ReplySize,
  DWORD                  Timeout
);

和上一节调用的 MessageBoxA 相比,这个函数的类型明显复杂得多。MessageBoxA 主要是窗口句柄、字符串指针和整数参数,而 IcmpSendEcho 同时涉及 handle、IPv4 地址、请求数据、请求长度、IP 选项、响应缓冲区、响应缓冲区大小和超时时间。要在 Rust 里调用它,第一步不是写业务逻辑,而是先把这些 C 类型翻译成 Rust 能理解的 FFI 类型。

Win32 API 里有很多历史类型别名。WORD 通常可以对应 Rust 的 u16DWORD 通常可以对应 u32LPVOID 是 Long Pointer to Void,可以用裸指针表示,例如 *const c_void*mut c_void,具体用不可变还是可变,要看这个参数是输入还是输出。HANDLE 在很多场景下也是一种不透明资源句柄,不需要也不应该直接解引用,可以先用 *const c_void 表示。

比较有意思的是 IPAddr。它表示一个 IPv4 地址。人类习惯把 IPv4 地址写成 x.y.z.w,比如 8.8.8.8,每一段都是 0 到 255 之间的数字。底层看,一个 IPv4 地址正好是 4 个字节。因此,用 Rust 表示它时,可以先从最直接的 [u8; 4] 开始。

但直接在函数签名里使用 [u8; 4] 并不理想。我们希望这个类型在语义上就表示 IP 地址,同时还希望给它实现自己的打印逻辑。于是可以使用 Rust 中非常常见的 newtype 模式。


二、用 newtype 表示 IPv4 地址

newtype 的写法很简单:用一个只有一个字段的结构体包住原始类型。例如:

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

这个类型在内存上本质就是 4 个字节,但在 Rust 类型系统中,它已经不再是普通的 [u8; 4]。这样做有两个好处。第一,它能表达更明确的业务含义,IPAddr([8, 8, 8, 8]) 明显比裸数组更像一个 IP 地址。第二,可以为它单独实现 trait,比如自定义 Debug 输出。

默认情况下,如果直接打印 [8, 8, 8, 8],看到的是数组格式。但对于 IP 地址,更自然的输出应该是 8.8.8.8。可以这样实现:

rust 复制代码
use std::fmt;

struct IPAddr([u8; 4]);

impl fmt::Debug for IPAddr {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let [a, b, c, d] = self.0;
        write!(f, "{}.{}.{}.{}", a, b, c, d)
    }
}

这里的 self.0 表示访问元组结构体里的第一个字段,也就是内部那段 [u8; 4]let [a, b, c, d] = self.0; 是数组解构,把四个字节分别绑定到四个变量上。因为 u8 实现了 Copy,所以这里可以直接拷贝出来。

测试一下:

rust 复制代码
fn main() {
    let addr = IPAddr([8, 8, 8, 8]);
    println!("addr = {:?}", addr);
}

输出就会变成:

text 复制代码
addr = 8.8.8.8

这个小例子非常能体现 newtype 的价值。它没有引入运行时开销,却让类型更有语义,也让输出更符合人的直觉。相比在代码里到处传 [u8; 4],用 IPAddr 能让后面的 FFI 签名和业务代码更清楚。


三、为什么 8.8.8.8 会变成 134744072

上一节用 API Monitor 观察 Windows ping.exe 时,曾经看到目标地址显示成一个整数:134744072。这个数字乍看和 8.8.8.8 没什么关系,但如果把 IP 地址按 4 个字节放进一个 32 位整数里,就能发现它们其实是同一个底层数据的不同解释方式。

可以写一段 Rust 代码,把 IPAddr([8, 8, 8, 8]) 重新解释成 u32

rust 复制代码
use std::mem::transmute;

fn main() {
    let addr = IPAddr([8, 8, 8, 8]);
    println!("addr = {:?}", addr);

    let addr_as_integer: u32 = unsafe { transmute(addr) };
    println!("addr_as_integer = {}", addr_as_integer);
}

这里用到了 transmute。它不会做格式转换,而是把一个类型的内存位模式直接重新解释成另一个类型。IPAddr([8, 8, 8, 8]) 正好是 4 字节,u32 也是 4 字节,所以可以在尺寸上匹配。解释出来的结果就会看到熟悉的 134744072

这说明 API Monitor 看到的并不是另一个地址,也不是 Windows 做了什么奇怪处理,而是它以整数形式展示了 IPv4 地址。底层 API 常常不会按照人类习惯的字符串形式展示数据,而是按照内存和整数来工作。理解这种差异,对系统编程非常重要。

当然,transmute 是危险工具。这里用它是为了演示"同一段内存可以被不同类型解释",不代表在业务代码里应该随意使用。跨语言调用时,类型大小、对齐、布局、字节序都可能影响结果。只要有一个前提不成立,transmute 就可能制造未定义行为。


四、处理 IP_OPTION_INFORMATION

IcmpSendEcho 的第五个参数是 PIP_OPTION_INFORMATION,它是指向 IP_OPTION_INFORMATION 结构体的指针。这个结构体用来指定 IP 头部选项。C 里的定义大致如下:

c 复制代码
typedef struct ip_option_information32 {
  UCHAR Ttl;
  UCHAR Tos;
  UCHAR Flags;
  UCHAR OptionsSize;
  UCHAR POINTER_32 *OptionsData;
} IP_OPTION_INFORMATION32, *PIP_OPTION_INFORMATION32;

字段含义可以先按名字理解。Ttl 是 Time To Live,也就是 IP 包最多允许经过多少跳;每经过一个路由器通常会减一,用来避免包在网络中无限循环。Tos 是 Type of Service,和服务类型相关。Flags 是标志位。OptionsSize 是选项数据大小。OptionsData 是指向选项数据的指针。

这里最奇怪的是 POINTER_32。如果当前运行在 64 位 Windows 上,普通指针是 64 位,那为什么这里还会出现 32 位指针?原因在于文档里明确说明,在 64 位平台上,这个参数使用的是 IP_OPTION_INFORMATION32 形式。也就是说,即使进程本身是 64 位,这个结构体里的选项数据指针仍然按照 32 位宽度处理。

可以把它理解为一种历史兼容和内核边界上的布局约定。32 位 Windows 时代已有这个结构体,后来 64 位 Windows 要兼容 32 位进程和 64 位进程,同时最终还要把数据交给内核网络栈处理。为了让不同架构下传入内核的数据布局保持一致,某些结构体就保留了 32 位形式。这里不需要深入追究 Windows 内核实现,只要知道一点:不能理所当然地把 C 里的指针字段都翻译成 Rust 的 64 位裸指针,必须按文档给出的 ABI 布局来写。

在这个简化版 ping 里,并不准备真正传递额外 IP options,因此 OptionsData 可以放 0。Rust 结构体可以先这样定义:

rust 复制代码
#[repr(C)]
struct IpOptionInformation {
    ttl: u8,
    tos: u8,
    flags: u8,
    options_size: u8,
    options_data: u32,
}

这里有一个非常关键的标注:#[repr(C)]。它不是装饰,不是为了好看,而是 FFI 正确性的核心。


五、为什么必须写 #[repr(C)]

Rust 结构体默认使用 Rust 自己的布局规则。编译器可以为了优化自由决定字段顺序、对齐方式和 padding 布局。也就是说,你在 Rust 代码里写的字段顺序,不一定等于内存里的真实字段顺序。对于纯 Rust 代码,这通常不是问题,因为所有读写都由 Rust 编译器掌控。但一旦要把结构体指针传给 C API,问题就来了。

C API 并不知道 Rust 编译器怎么布局结构体。它只会按 C 结构体的布局解释内存。如果 Rust 传过去的内存布局和 C 期望的不一致,C API 就会从错误位置读取字段。比如它以为某个字节是 TTL,实际那里可能是 Rust 重新排列后的其他字段;它以为某个位置是指针,实际那里可能是 padding 或另一个字段。这种错误不会被编译器发现,因为 FFI 声明都是程序员自己写的,编译器只能相信你。

#[repr(C)] 的作用就是告诉 Rust:这个结构体要按照 C 语言兼容的方式布局。这样才能把它安全地传给 C API,或者从 C API 写入的内存中按结构体读取数据。

为了理解布局问题,可以看一个简化结构体:

rust 复制代码
struct Foo {
    a: u8,
    b: u32,
}

直觉上,a 占 1 字节,b 占 4 字节,总共应该是 5 字节。但实际并不是这么简单。多数平台上,u32 更适合放在 4 字节对齐的地址上。为了满足对齐要求,编译器可能在 a 后面插入 3 个字节 padding,让 b 从 4 的倍数地址开始。这样 Foo 的总大小可能是 8 字节,而不是 5 字节。

也可以使用 #[repr(packed)] 强行紧凑布局:

rust 复制代码
#[repr(packed)]
struct FooPacked {
    a: u8,
    b: u32,
}

这样结构体可以变成更紧凑的 5 字节,但紧凑不一定总是好事。未对齐访问在某些架构上可能性能差,甚至不被允许。系统编程不能只看字段大小相加,还必须考虑对齐和目标平台。

在 FFI 场景下,真正的问题不是"紧凑还是不紧凑",而是"Rust 传出去的布局必须和 C API 期待的布局一致"。因此,对应 C 结构体时通常应该使用 #[repr(C)]


六、Rust 默认布局真的会影响这个结构体

IpOptionInformation 这个结构体看起来字段顺序已经很自然:四个 u8 加一个 u32。直觉上,前四个字节刚好组成 4 字节,后面再放一个 u32,好像 Rust 默认布局和 C 布局应该一样。但不能依赖直觉。

Rust 默认布局允许编译器调整字段顺序。例如它可能把 u32 放到前面,再把几个 u8 放到后面。这样总大小可能仍然一样,但字段偏移已经变了。对于 Rust 自己来说没问题,因为 Rust 知道自己怎么放的;但对于 C API 来说就是灾难。

可以用类似 memoffset 这样的工具检查字段偏移。比较一个普通 Rust repr 的结构体和一个 #[repr(C)] 的结构体,就会发现字段偏移可能不同。普通 Rust 结构体中,字段顺序可能被重新排列;C repr 结构体则会保留与 C 兼容的字段顺序和对齐方式。

在这个例子里,如果不写 #[repr(C)],传给 IcmpSendEcho 的 IP 选项结构体就可能是错的。TTL、TOS、Flags、OptionsSize、OptionsData 的位置都可能和 Win32 API 期待的不一致。程序不一定立刻崩溃,但 API 收到的参数会变成垃圾数据。

FFI 最危险的地方就在这里:编译器没有能力替你检查外部函数真实需要什么布局。所有外部函数声明、结构体布局、指针类型、调用约定都由你自己写。你写错了,编译器仍然可能照样生成代码,因为从 Rust 视角看,你提供的声明就是"事实"。


七、定义 IcmpSendEcho 的 Rust 函数指针类型

理解了相关类型之后,就可以把 IcmpSendEcho 写成 Rust 函数指针类型。先定义 handle:

rust 复制代码
use std::ffi::c_void;

type Handle = *const c_void;

然后定义 IcmpSendEcho

rust 复制代码
type IcmpSendEcho = extern "stdcall" fn(
    handle: Handle,
    dest: IPAddr,
    request_data: *const u8,
    request_size: u16,
    request_options: Option<&IpOptionInformation>,
    reply_buffer: *mut u8,
    reply_size: u32,
    timeout: u32,
) -> u32;

这里有几个地方值得仔细看。

dest: IPAddr 表示目标 IPv4 地址。因为 IPAddr 包了一层 [u8; 4],它在底层可以作为 4 字节地址传给 Win32 API。真实工程里最好再加上适当的 repr 标注来更明确表达布局意图,但这里先沿用文章里的逐步推导。

request_data: *const u8 表示要随 ICMP Echo 请求一起发送的数据。ping 包里可以带任意 payload。Windows 自带 ping 通常会发送一串字母;这里可以放一段自己构造的文本。因为请求数据只是被读取,所以用 *const u8

request_size: u16 对应 WORD,表示请求数据长度。

request_options 对应 PIP_OPTION_INFORMATION。这个参数可以为 NULL,表示不传 IP 头部选项。Rust 里可以直接写成 *const IpOptionInformation,然后需要手动传空指针或结构体指针。但这里使用了更 Rust 的写法:Option<&IpOptionInformation>。在 FFI 边界上,Option<&T> 可以表示一个可空指针:Some(&value) 对应非空指针,None 对应 NULL。这样在 Rust 代码里用起来比裸指针舒服一些。

reply_buffer: *mut u8 是输出缓冲区。IcmpSendEcho 会把结果写到这里,所以它必须是可变指针,而不是 *const u8。一开始还不知道缓冲区里到底要解析成什么结构,因此先把它当成字节缓冲区。

reply_size: u32 表示缓冲区大小,timeout: u32 表示等待响应的毫秒数。返回值是 u32,但它不是传统意义上的错误码。


八、还需要 IcmpCreateFile

IcmpSendEcho 的第一个参数是 IcmpHandle。这个 handle 不能凭空编出来,需要先调用 IcmpCreateFile 创建。IcmpCreateFile 的 C 声明很简单:

c 复制代码
IPHLPAPI_DLL_LINKAGE HANDLE IcmpCreateFile();

Rust 里可以写成:

rust 复制代码
type IcmpCreateFile = extern "stdcall" fn() -> Handle;

上一节已经学过如何动态加载 DLL 和获取函数地址,所以现在可以从 IPHLPAPI.dll 中同时取出 IcmpCreateFileIcmpSendEcho

rust 复制代码
unsafe {
    let h = LoadLibraryA("IPHLPAPI.dll\0".as_ptr());

    let IcmpCreateFile: IcmpCreateFile =
        transmute(GetProcAddress(h, "IcmpCreateFile\0".as_ptr()));

    let IcmpSendEcho: IcmpSendEcho =
        transmute(GetProcAddress(h, "IcmpSendEcho\0".as_ptr()));

    let handle = IcmpCreateFile();
    println!("handle = {:?}", handle);
}

如果打印出来的 handle 不是空指针,说明至少 DLL 加载成功,函数名也拼对了。到这里还没有完整错误处理。真正工程代码应该检查 LoadLibraryAGetProcAddressIcmpCreateFile 的返回值,并在失败时调用 GetLastError 获取具体错误。但在探索阶段,先跑通主线也可以。


九、第一次真正调用 IcmpSendEcho

有了 handle 和函数指针之后,就可以发出一次请求。请求数据可以随便放一段文本,例如:

rust 复制代码
let data = "O Romeo, Romeo. Reachable art thou Romeo?";

再准备 IP 选项:

rust 复制代码
let ip_opts = IpOptionInformation {
    ttl: 128,
    tos: 0,
    flags: 0,
    options_data: 0,
    options_size: 0,
};

这里 TTL 选择 128。TOS、Flags 和 options 相关字段先设为 0,表示不使用额外 IP 选项。

接下来准备响应缓冲区。第一次可以粗略分配 128 字节:

rust 复制代码
let mut reply = vec![0u8; 128];

然后调用:

rust 复制代码
let ret = IcmpSendEcho(
    handle,
    IPAddr([8, 8, 8, 8]),
    data.as_ptr(),
    data.len() as u16,
    Some(&ip_opts),
    reply.as_mut_ptr(),
    reply.len() as u32,
    4000,
);

println!("ret = {}", ret);

最后一个参数 4000 表示最多等待 4000 毫秒,也就是 4 秒。

如果网络正常,返回值可能是:

text 复制代码
ret = 1

很多 Win32 API 习惯返回 0 表示成功,非 0 表示错误,所以第一次看到 1 容易产生怀疑。但 IcmpSendEcho 不一样。它的返回值表示写入 ReplyBufferICMP_ECHO_REPLY 结构体数量。返回 1 表示收到了一个响应,反而是好消息。如果返回 0,才应该调用 GetLastError 查看错误原因。

这一步已经完成了最朴素意义上的"自己的 ping":Rust 程序通过 Win32 API 向 8.8.8.8 发出了 ICMP Echo 请求,并且收到了一个响应。

但这还远远不够。因为我们还没有解析 reply 缓冲区。只知道 API 返回了 1,并不知道对方地址、状态、往返时间、返回数据到底是什么。


十、用 hex dump 看看 reply buffer 里有什么

既然 IcmpSendEcho 把结果写进了 reply 缓冲区,就应该看看这个缓冲区到底长什么样。可以用 pretty-hex 这样的 crate 把字节打印成十六进制视图。

添加依赖后,在调用之后打印:

rust 复制代码
use pretty_hex::*;

println!("{:?}", reply.hex_dump());

十六进制输出里会看到一些非文本数据,也会看到一个很有意思的现象:前面发送出去的请求数据又回来了。也就是说,ICMP Echo Reply 会带回 Echo Request 中的 payload。这也是 ping 常见行为:你发出去的数据会在响应里被原样带回,用来确认对方确实收到了这份数据并返回。

但 reply buffer 不是只有 payload。它前面还有结构化数据。Microsoft 文档说明,响应缓冲区中包含一个或多个 ICMP_ECHO_REPLY 结构体,后面跟着 options 和 data。也就是说,不能把整个 reply buffer 简单当成字符串或字节数组理解。需要先按结构体读取头部信息,再根据结构体里的字段找到返回数据。

ICMP_ECHO_REPLY 的 C 结构体大致如下:

c 复制代码
typedef struct icmp_echo_reply {
  IPAddr Address;
  ULONG Status;
  ULONG RoundTripTime;
  USHORT DataSize;
  USHORT Reserved;
  PVOID Data;
  struct ip_option_information Options;
} ICMP_ECHO_REPLY, *PICMP_ECHO_REPLY;

这里的字段基本都能映射到前面已经理解的类型。Address 是 IPv4 地址,Status 是状态码,RoundTripTime 是往返时间,DataSize 是返回数据长度,Reserved 是保留字段,Data 是指向返回数据的指针,Options 是 IP 选项信息。

Rust 里可以先定义:

rust 复制代码
#[repr(C)]
#[derive(Debug)]
struct IcmpEchoReply {
    address: IPAddr,
    status: u32,
    rtt: u32,
    data_size: u16,
    reserved: u16,
    data: *const u8,
    options: IpOptionInformation,
}

因为这个结构体要对应 C 的 ICMP_ECHO_REPLY,所以也必须使用 #[repr(C)]。为了方便打印,给它加上 #[derive(Debug)]。由于它内部包含 IpOptionInformation,后者也需要实现 Debug,可以加上:

rust 复制代码
#[repr(C)]
#[derive(Debug)]
struct IpOptionInformation {
    ttl: u8,
    tos: u8,
    flags: u8,
    options_size: u8,
    options_data: u32,
}

十一、尝试用 MaybeUninit 接收一个结构体

既然 IcmpSendEcho 会写入一个 ICMP_ECHO_REPLY 结构体,那么一个自然想法是:直接准备一个未初始化的 IcmpEchoReply,把它的指针传给 API,让 API 填好它。Rust 里可以用 MaybeUninit 表示"这里有一块可能尚未初始化的内存"。

MaybeUninit<T> 的意义是告诉 Rust:这块内存将来可能会变成一个 T,但现在不能把它当成已经初始化的 T 使用。它适合处理 C API 中常见的 out pointer,也就是"调用方提供一块内存,被调用方往里面写结果"的模式。

可以先把 IcmpSendEchoreply_buffer 参数改成:

rust 复制代码
reply_buffer: *mut IcmpEchoReply,

然后这样写:

rust 复制代码
use std::mem;

let mut reply: mem::MaybeUninit<IcmpEchoReply> =
    mem::MaybeUninit::uninit();

let ret = IcmpSendEcho(
    handle,
    IPAddr([8, 8, 8, 8]),
    data.as_ptr(),
    data.len() as u16,
    Some(&ip_opts),
    reply.as_mut_ptr(),
    mem::size_of::<IcmpEchoReply>() as u32,
    4000,
);

if ret == 0 {
    panic!("IcmpSendEcho failed! ret = {}", ret);
}

let reply = reply.assume_init();
println!("{:#?}", reply);

这段代码在思想上很干净。先分配一块未初始化内存,把它交给 Windows API 写入;只有当 IcmpSendEcho 成功返回后,才调用 assume_init(),告诉 Rust 这块内存现在已经被初始化成合法的 IcmpEchoReply

这个思路对很多 out pointer 场景都是正确的,但这里会失败。原因不在 MaybeUninit 本身,而在 IcmpSendEcho 对 reply buffer 的要求比一个结构体更大。


十二、reply buffer 不只是一个结构体

IcmpSendEcho 的文档要求 reply buffer 至少要能容纳一个 ICMP_ECHO_REPLY 结构体,加上请求数据长度对应的返回数据,还要再额外留出 8 字节,用于 ICMP 错误消息。这意味着,reply buffer 不是一个单独的 IcmpEchoReply,而是一段连续内存,里面包含多种内容。

可以把它理解成下面这种布局:

text 复制代码
+-----------------------+
| ICMP_ECHO_REPLY       |
+-----------------------+
| 额外 8 字节错误空间    |
+-----------------------+
| Echo reply data       |
+-----------------------+

第一次用 MaybeUninit<IcmpEchoReply> 失败,就是因为只给了结构体本身的大小,没有给请求数据和额外 8 字节预留空间。而 IcmpSendEcho 需要把这些内容都写入同一个 reply buffer,所以会认为缓冲区太小。

这也是 FFI 编程里非常典型的陷阱。C API 的参数写着 LPVOID ReplyBuffer,看上去只是一个 void 指针,但真实语义由文档规定。它不是指向某个单独结构体的指针,而是指向一段手工布局的字节缓冲区。你必须按文档计算大小,并在返回后再自己解析这段内存。

因此,这里要暂时放弃"直接传一个结构体"的想法,改回字节缓冲区。


十三、正确分配 reply buffer

正确做法是先计算 IcmpEchoReply 结构体大小,再加上 8 字节,再加上请求数据长度:

rust 复制代码
use std::mem;

let reply_size = mem::size_of::<IcmpEchoReply>();
let reply_buf_size = reply_size + 8 + data.len();
let mut reply_buf = vec![0u8; reply_buf_size];

然后 IcmpSendEcho 的签名也改回让 reply_buffer 接收 *mut u8

rust 复制代码
type IcmpSendEcho = extern "stdcall" fn(
    handle: Handle,
    dest: IPAddr,
    request_data: *const u8,
    request_size: u16,
    request_options: Option<&IpOptionInformation>,
    reply_buffer: *mut u8,
    reply_size: u32,
    timeout: u32,
) -> u32;

调用时传入字节缓冲区:

rust 复制代码
let ret = IcmpSendEcho(
    handle,
    IPAddr([8, 8, 8, 8]),
    data.as_ptr(),
    data.len() as u16,
    Some(&ip_opts),
    reply_buf.as_mut_ptr(),
    reply_buf_size as u32,
    4000,
);

if ret == 0 {
    panic!("IcmpSendEcho failed! ret = {}", ret);
}

这次就有足够空间容纳结构体、额外错误空间和返回数据。API 返回成功后,接下来要做的是从这段字节缓冲区里解析出结构体和数据。


十四、从字节缓冲区解释出 IcmpEchoReply

reply_bufVec<u8>,也就是字节数组。但它开头那一段内存其实是一个 IcmpEchoReply。要读取它,可以把缓冲区起始位置的地址重新解释成 &IcmpEchoReply

rust 复制代码
let reply: &IcmpEchoReply = mem::transmute(&reply_buf[0]);
println!("{:#?}", *reply);

这仍然是 unsafe 世界里的操作。因为 Rust 并不知道 reply_buf[0] 后面的字节是否真的构成一个合法的 IcmpEchoReply,也不知道对齐是否满足要求。这里能这么做,是因为我们知道这段内存刚刚由 IcmpSendEcho 按照 ICMP_ECHO_REPLY 的 C 布局写入,并且结构体定义用了 #[repr(C)]

更严谨的写法在实际工程里可以使用指针 cast、align_toread_unaligned 或其他方式更明确地处理对齐和所有权,但这篇先用 transmute 展示核心思路:把原始字节解释成结构化数据。

打印出来的 reply 会包含地址、状态、往返时间、数据大小、数据指针和 IP 选项等字段。这样就不只是知道 ret = 1,而是能看到这次 ping 的具体响应信息。


十五、读取返回的 Echo 数据

结构体之后还有返回数据。根据前面的布局,返回数据不是紧跟在 IcmpEchoReply 后面,而是中间还有 8 字节用于 ICMP 错误消息。因此,返回数据的起始位置是:

rust 复制代码
reply_size + 8

可以先拿到这个位置的指针:

rust 复制代码
let reply_data: *const u8 = mem::transmute(&reply_buf[reply_size + 8]);

然后根据 reply.data_size 构造 slice:

rust 复制代码
let reply_data = std::slice::from_raw_parts(
    reply_data,
    reply.data_size as usize,
);

最后用 hex dump 打印:

rust 复制代码
use pretty_hex::*;

println!("{:?}", reply_data.hex_dump());

这时可以看到,返回数据正是前面发送出去的请求数据。也就是说,ICMP Echo Reply 把 Echo Request 的 payload 原样带回来了。到这里,这个自制 ping 才真正具备了可观察性:不仅知道请求成功,还能解析出响应结构体,并确认返回数据和发送数据一致。


十六、完整程序目前长什么样

把前面的内容合在一起,程序大致如下。为了突出主线,这里仍然保留了动态加载 DLL、GetProcAddresstransmute 和 unsafe 调用的写法:

rust 复制代码
use pretty_hex::*;
use std::{
    ffi::c_void,
    fmt,
    mem::{size_of, transmute},
    slice,
};

type HModule = *const c_void;
type FarProc = *const c_void;

extern "stdcall" {
    fn LoadLibraryA(name: *const u8) -> HModule;
    fn GetProcAddress(module: HModule, name: *const u8) -> FarProc;
}

struct IPAddr([u8; 4]);

impl fmt::Debug for IPAddr {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let [a, b, c, d] = self.0;
        write!(f, "{}.{}.{}.{}", a, b, c, d)
    }
}

#[repr(C)]
#[derive(Debug)]
struct IpOptionInformation {
    ttl: u8,
    tos: u8,
    flags: u8,
    options_size: u8,
    options_data: u32,
}

type Handle = *const c_void;

#[repr(C)]
#[derive(Debug)]
struct IcmpEchoReply {
    address: IPAddr,
    status: u32,
    rtt: u32,
    data_size: u16,
    reserved: u16,
    data: *const u8,
    options: IpOptionInformation,
}

type IcmpSendEcho = extern "stdcall" fn(
    handle: Handle,
    dest: IPAddr,
    request_data: *const u8,
    request_size: u16,
    request_options: Option<&IpOptionInformation>,
    reply_buffer: *mut u8,
    reply_size: u32,
    timeout: u32,
) -> u32;

type IcmpCreateFile = extern "stdcall" fn() -> Handle;

fn main() {
    #[allow(non_snake_case)]
    unsafe {
        let h = LoadLibraryA("IPHLPAPI.dll\0".as_ptr());

        let IcmpCreateFile: IcmpCreateFile =
            transmute(GetProcAddress(h, "IcmpCreateFile\0".as_ptr()));

        let IcmpSendEcho: IcmpSendEcho =
            transmute(GetProcAddress(h, "IcmpSendEcho\0".as_ptr()));

        let handle = IcmpCreateFile();

        let data = "O Romeo, Romeo. Reachable art thou Romeo?";

        let ip_opts = IpOptionInformation {
            ttl: 128,
            tos: 0,
            flags: 0,
            options_data: 0,
            options_size: 0,
        };

        let reply_size = size_of::<IcmpEchoReply>();
        let reply_buf_size = reply_size + 8 + data.len();

        let mut reply_buf = vec![0u8; reply_buf_size];

        let ret = IcmpSendEcho(
            handle,
            IPAddr([8, 8, 8, 8]),
            data.as_ptr(),
            data.len() as u16,
            Some(&ip_opts),
            reply_buf.as_mut_ptr(),
            reply_buf_size as u32,
            4000,
        );

        if ret == 0 {
            panic!("IcmpSendEcho failed! ret = {}", ret);
        }

        let reply: &IcmpEchoReply = transmute(&reply_buf[0]);
        println!("{:#?}", *reply);

        let reply_data: *const u8 =
            transmute(&reply_buf[reply_size + 8]);

        let reply_data =
            slice::from_raw_parts(reply_data, reply.data_size as usize);

        println!("{:?}", reply_data.hex_dump());
    }
}

这段程序已经可以完成一次真正的 ICMP Echo 请求。它动态加载 IPHLPAPI.dll,获取 IcmpCreateFileIcmpSendEcho 的地址,创建 ICMP handle,构造 IP 地址和 IP 选项,发送请求,接收响应,再把响应缓冲区解析成 IcmpEchoReply 和返回数据。

不过,它还不是理想工程代码。它缺少完整错误处理,没有封装 unsafe 边界,没有释放 ICMP handle,也没有把命令行参数、域名解析、IPv6、超时配置、输出格式等功能做好。它更像是一个能跑通底层路径的原型,用来证明 Rust 可以通过 Win32 API 发出 ping 请求。


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

这一篇表面上是在写 ping,实际上是在学习 Rust FFI 的几个核心原则。

第一个原则是:FFI 边界上的类型必须清楚。C 里的 DWORDWORDHANDLELPVOIDIPAddr 不能凭感觉翻译。要知道哪个是 16 位,哪个是 32 位,哪个是不可变指针,哪个是输出指针,哪个只是系统资源句柄,哪个需要按特定布局传递。

第二个原则是:newtype 能让底层类型更有语义。IPAddr([u8; 4]) 相比裸 [u8; 4] 更清晰,也能实现自己的 Debug 输出。系统编程里经常面对大量整数、指针和字节数组,如果不做适度封装,代码很快会变成"到处都是 u32 和 *const u8",读起来很难判断语义。

第三个原则是:C 结构体必须用 #[repr(C)] 对齐布局。Rust 默认结构体布局不保证字段顺序和 C 一致。只要一个结构体要传给 C,或者要从 C 写入的内存中读取,就必须认真考虑布局、对齐和 padding。否则程序可能不崩溃,却在悄悄传错参数。

第四个原则是:输出缓冲区不能只看类型名,要看文档语义。ReplyBufferLPVOID,但它并不是一个单独的 ICMP_ECHO_REPLY。它是一段字节区域,里面包含结构体、额外错误空间和返回数据。C API 经常用一个裸指针承载复杂内存布局,这时 Rust 这边必须按文档计算空间并手工解析。

第五个原则是:MaybeUninit 适合 out pointer,但前提是缓冲区语义真的匹配。如果外部函数只写入一个 T,那 MaybeUninit<T> 很合适;但如果外部函数写入的是"一个结构体加额外变长数据",单个 MaybeUninit<T> 就不够了。MaybeUninit 解决的是未初始化内存问题,不会自动解决缓冲区布局问题。

第六个原则是:unsafe 应该尽量被限制在小范围里。现在的代码把动态加载、函数指针转换、外部函数调用、指针重解释、slice 构造都放在一个大的 unsafe 块里。它能跑,但不够好。后续应该把这些危险操作封装起来,对外提供更安全、更 Rust 风格的接口。


十八、从"能 ping 通"到"写得像一个程序"

到这里,自己的 ping 已经能发出请求并收到响应,但还没有达到真正可维护程序的状态。一个可用的工具至少还需要处理这些问题。

首先,要处理错误。LoadLibraryA 可能失败,GetProcAddress 可能找不到函数,IcmpCreateFile 可能返回无效 handle,IcmpSendEcho 可能超时、缓冲区不足、参数错误或网络不可达。现在的代码大多只是乐观地假设每一步都会成功,真实程序不能这样写。

其次,要封装类型。IPAddrIpOptionInformationIcmpEchoReplyHandleIcmpSendEcho 这些类型最好分层组织。底层模块负责 FFI 绑定和 unsafe 调用,上层模块提供安全 API,例如 ping_ipv4(IPAddr) -> Result<PingReply, Error>。这样业务代码就不需要直接接触 transmute 和裸指针。

再次,要处理资源释放。IcmpCreateFile 创建出来的 handle 应该在适当时候关闭。Windows 通常会提供对应关闭函数,例如 ICMP API 里有用于关闭 ICMP handle 的函数。安全封装里可以用 Rust 的 Drop 自动释放资源,避免调用方忘记清理。

然后,要让输入更自然。现在目标地址硬编码为 8.8.8.8,实际工具应该允许从命令行读取地址,可能还要支持域名解析。如果用户输入 example.com,程序需要先解析成 IP 地址,再发送请求。IPv6 还需要另一套 API 和地址类型。

最后,要把输出做成人能读懂的格式。现在只是打印 Debug 和 hex dump。真正的 ping 工具应该显示目标地址、响应时间、TTL、数据大小、丢包率、统计信息等。hex dump 对调试很有用,但对普通用户不是最终输出。

所以,这一篇的成果可以理解成一个关键里程碑:底层 ICMP 调用已经打通,接下来要做的是整理代码结构、补错误处理、封装 unsafe、增加功能,把实验代码逐步变成一个像样的命令行工具。


十九、总结

这一篇真正完成了从"能调用 Win32 API"到"能发送 ICMP Echo 请求"的跨越。上一节只是用 Rust 动态加载 DLL 并调用 MessageBoxA,这一节则把同样的技术用在 IPHLPAPI.dll 上,调用 IcmpCreateFile 创建 ICMP handle,再调用 IcmpSendEcho8.8.8.8 发送请求。

过程中最重要的不是某一行代码,而是一整套 FFI 思维。C API 的函数签名必须逐项翻译,Win32 类型别名必须映射到正确的 Rust 类型,IPv4 地址可以用 newtype 表达,C 结构体必须使用 #[repr(C)],输出缓冲区必须按照文档计算大小,返回的字节缓冲区需要再解释成结构体和数据。MaybeUninit 提供了一种处理未初始化内存的安全边界,但它不能替代对缓冲区布局的理解。

Rust 的安全性并不是让底层问题消失,而是让危险边界更明确。在这段代码里,所有真正危险的部分都集中在 unsafe 中:动态函数地址转换、外部函数调用、裸指针传递、字节缓冲区转结构体、原始指针转 slice。只要后续把这些操作封装好,就可以把底层 Win32 API 包成相对安全、清晰的 Rust 接口。

到这里,自己的 ping 已经可以工作,但还不够漂亮。它已经证明了:Rust 程序可以不依赖现成 ping 命令,而是直接通过 Windows ICMP API 发包并读取响应。下一步要做的,不是继续堆更多 unsafe,而是重构代码,把类型、错误、资源和输出都整理成更可靠的结构。真正的系统编程并不只是把函数调通,更重要的是在调通之后,把那些容易出错的底层细节收束到可维护的边界里。

相关推荐
Boop_wu1 小时前
[Spring Cloud] 快速上手nacos
后端·spring·spring cloud
塵觴葉1 小时前
基于Lua协程的简单任务管理
开发语言·lua
liulilittle1 小时前
甲骨文云中国大陆定向 QoS 原理及绕过解决方案
服务器·开发语言·网络·计算机网络·oracle·通信·qos
iCxhust1 小时前
C# 生成命令行程序 将hex格式烧录程序转换成bin烧录格式
开发语言·汇编·单片机·嵌入式硬件·c#·微机原理
Mortalbreeze1 小时前
C++11类的新特性:移动语义、default、delete、override详解
开发语言·c++
xiaoshuaishuai81 小时前
C# 封装与继承
开发语言·c#
糖果店的幽灵1 小时前
软件测试接口测试从入门到精通:RESTful API设计规范
软件测试·后端·接口测试·restful·设计规范·api设计
星辰_mya2 小时前
限流、漏斗桶和令牌桶的区别
java·开发语言·面试·架构·高并发
Shadow(⊙o⊙)2 小时前
信号1.0,信号概念、signal()处理、前后台进程、闹钟设置、初识信号三张表。
linux·运维·服务器·开发语言·c++