从 Windows 的 ping.exe 入手:动态库、调用约定与 Rust FFI

本文是对 Windows dynamic libraries, calling conventions, and transmute 的整理与翻译。

内容结构概览

  1. 从 ping.exe 开始逆向观察 :真正的 ping 不会自己实现所有协议,而是调用 Windows 系统库。
  2. 用 dumpbin 查看依赖库 :通过 Visual Studio 工具链中的 dumpbin /dependents 查看 PING.EXE 依赖哪些 DLL。
  3. 定位 ICMP 相关函数 :通过 dumpbin /importsfindstr 找到 IcmpSendEcho2ExIcmp6SendEcho2
  4. 找到 IPHLPAPI.dll :ICMP 相关函数来自 IP Helper API,也就是 IPHLPAPI.dll
  5. 用 API Monitor 观察真实调用 :通过监控 PING.EXE,看到创建 ICMP handle、调用 IcmpSendEcho2Ex、默认 TTL 等信息。
  6. 转向 Rust 实现 :使用 stable-x86_64-pc-windows-msvc 工具链,保证和 Visual Studio/MSVC ABI 兼容。
  7. 不直接链接 IPHLPAPI,而是运行时加载 :通过 KERNEL32.dll 提供的 LoadLibraryAGetProcAddress 动态打开 DLL、取函数地址。
  8. 理解 Win32 字符串和调用约定A/W 后缀、C 字符串、空字节结尾、stdcall 调用约定。
  9. Rust FFI 基础extern "stdcall"、裸指针、c_voidHMODULEunsafe
  10. 第一个坑:字符串没有 \0 结尾:Rust 字符串切片不是 C 字符串,传给 Win32 API 时要手动补空字节。
  11. GetProcAddress 与函数指针:拿到函数地址后,不能直接当函数调用。
  12. transmute 登场 :把原始地址重新解释成函数指针,用来调用 MessageBoxA
  13. 总结:这一篇不是直接实现 ping,而是先打通 Rust 调用 Win32 API 的基础能力。

上一篇文章从计算机网络的历史讲起,解释了为什么会有以太网、MAC 地址、IP、DNS、DHCP,以及为什么 ping 这样一个小工具背后牵扯到整套网络协议栈。到了这一篇,问题终于开始落到具体实现上:Windows 自带的 ping.exe 到底是怎么发出 ping 请求的?

直觉上,ping.exe 不太可能自己从零实现所有底层协议。它不应该自己管理网卡,不应该自己处理整个 IP 协议栈,也不应该绕过操作系统直接和硬件说话。真正合理的模型是:ping.exe 调用某个 Windows 系统库,系统库再进入内核或网络栈,由操作系统负责完成实际的数据包发送与接收。

因此,这一篇的目标不是马上写出完整 ping,而是先学会观察现有程序如何调用 Windows API,再用 Rust 复现这种调用方式。这个过程会涉及 Windows 动态库、PE/COFF 文件、dumpbinIPHLPAPI.dllLoadLibraryAGetProcAddress、调用约定、C 字符串、裸指针、unsafetransmute。这些东西看起来离 ping 很远,但如果想在 Windows 上自己实现一个低层网络工具,绕不开它们。


一、ping.exe 不会独自完成所有工作

在 Windows 上,ping.exe 位于:

text 复制代码
C:\Windows\System32\PING.EXE

它是一个普通可执行文件。用户在命令行里输入:

text 复制代码
ping 8.8.8.8

屏幕上会显示请求是否成功、往返时间、TTL 等信息。表面看起来,这是 ping.exe 自己做的事;但从操作系统结构看,它很可能只是调用了系统提供的 API。

原因很简单。发送 ICMP Echo 请求需要经过操作系统网络栈,最终还会通过网卡把数据发到外部网络。普通应用程序不应该直接操作硬件,也不应该复制一份完整网络协议栈。Windows 已经提供了相关系统库和内核接口,ping.exe 更合理的做法是调用这些接口。

在 Linux 上,如果想知道一个可执行文件依赖哪些动态库,常用工具是 ldd。但在 Windows 上,工具名称和生态不一样。Windows 可执行文件通常是 PE/COFF 格式,动态库是 .dll 文件。要查看 Windows 可执行文件依赖哪些 DLL,可以使用 Visual Studio 工具链里自带的 dumpbin

这就引出了第一步:搭建一个能分析 Windows 可执行文件的环境。


二、用 Visual Studio 工具链查看 PING.EXE 依赖

在 Windows 上,按照 Microsoft 官方方式构建和分析程序,通常需要安装 Visual Studio 或 Visual Studio Build Tools。完整 Visual Studio 体积不小,但它包含很多有用工具,例如编译器、链接器、调试器、分析工具,以及这里要用到的 dumpbin

安装 Visual Studio 之后,可以打开 "Developer Command Prompt" 或类似名称的工具命令行。它本质上还是 cmd.exe,但已经配置好了环境变量,可以直接使用 Visual Studio 工具链中的命令。普通命令行里可能找不到 dumpbin,但开发者命令行可以。

先用下面的命令查看 PING.EXE 依赖了哪些动态库:

text 复制代码
dumpbin /dependents C:\Windows\System32\PING.EXE

这里的 /dependentsdumpbin 的参数。Microsoft 命令行工具经常使用 /flag 这种风格,而不是 Unix 工具里常见的 --flag

输出结果会列出一组 DLL。除了 msvcrt.dllntdll.dll、各种 api-ms-win-core-* 之外,还能看到两个比较关键的库:

text 复制代码
IPHLPAPI.DLL
WS2_32.dll

WS2_32.dll 很容易让人想到 Windows Sockets,也就是网络编程常见的 Winsock。IPHLPAPI.DLL 则更像 "IP Helper API",直觉上和 IP 网络管理、ICMP、路由表、网卡信息等有关。

但光看到依赖库还不够。一个程序依赖某个 DLL,并不代表 ping 功能一定来自它。下一步需要查看 PING.EXE 从这些 DLL 里具体导入了哪些函数。


三、从导入函数里找到 ICMP Echo

继续使用 dumpbin,可以查看一个可执行文件导入了哪些外部函数:

text 复制代码
dumpbin /imports C:\Windows\System32\PING.EXE

完整输出会比较长,所以可以配合 findstr 搜索感兴趣的关键词。findstr 可以粗略理解为 Windows 里的 grep。先搜索 ping

text 复制代码
dumpbin /imports C:\Windows\System32\PING.EXE | findstr /I ping

/I 表示大小写不敏感。这样可以同时匹配 pingPINGPing 等形式。

搜索 ping 可能没有结果,因为 API 名字未必叫 ping。换一个思路,ping 的底层语义是 ICMP Echo Request 和 Echo Reply,所以可以搜索 echo

text 复制代码
dumpbin /imports C:\Windows\System32\PING.EXE | findstr /I echo

这时能看到类似下面的函数名:

text 复制代码
IcmpSendEcho2Ex
Icmp6SendEcho2

这就基本确认了方向。IPv4 的 ping 很可能走 IcmpSendEcho2Ex,IPv6 的 ping 则可能走 Icmp6SendEcho2。这两个名字已经非常明确:它们属于 ICMP Echo 相关 API。

接下来还要确认这些函数来自哪个 DLL。dumpbin /imports 的完整输出会按 DLL 分组列出导入函数,简单搜索函数名只能看到函数,未必能看到分组上下文。可以用 PowerShell 做更方便的文本处理,或者直接查看完整输出。最终可以定位到:相关函数来自 IPHLPAPI.dll

这一步非常关键。到这里,已经知道 Windows 自带 ping.exe 并不是自己手搓 ICMP,而是调用了 IP Helper API 里的 ICMP 相关函数。后续自己写程序时,可以沿着同一条路线调用这些函数。


四、文档有用,但真实程序更有用

查到 IcmpSendEcho2Ex 之后,当然可以直接打开 Microsoft 文档,看它的函数签名、参数含义、返回值说明。但只看文档有一个问题:文档告诉你 API "可以怎么用",却不一定告诉你一个真实程序"实际怎么用"。

尤其是 Win32 API 这类历史悠久的接口,经常有大量参数、结构体、可选项、兼容行为。文档读起来可能很完整,但第一次调用时仍然不知道哪些参数必须填,哪些可以传空,哪些结构体必须提前初始化,哪些行为只是示例代码里的习惯。

这时可以用 API Monitor 观察真实程序。API Monitor 是一个可以监控 Windows API 调用的工具。它加载大量 API 定义,然后允许选择某一组 API 进行 hook,再启动或附加到目标进程,观察目标程序到底调用了哪些函数、传了什么参数、返回了什么结果。

这里可以选择 IP Helper 相关 API,然后监控新启动的 PING.EXE。执行几次 ping 之后,就能看到它调用了哪些 ICMP 函数,以及参数大概是什么样子。

从监控结果里可以得到几条重要信息。首先,发送 ping 之前需要创建一个 ICMP handle。其次,IcmpSendEcho2Ex 参数很多,但不少参数可以留空。再次,Windows 的 PING.EXE 默认选择的 TTL 是 128。TTL 是 Time To Live,它表示一个 IP 包最多可以经过多少次路由跳转;每经过一个路由器,TTL 通常减一,减到 0 就会被丢弃,用来避免数据包在网络中无限循环。

监控结果里还会出现一个看起来奇怪的目标地址,比如 134744072。它不像常见的 IP 地址写法,但如果把它按字节解释,就会发现它其实是 8.8.8.8。每个 8 都是一个字节,API Monitor 把四个字节整体解释成了 32 位整数,所以显示成了十进制整数。这是观察底层 API 时很常见的现象:工具展示的数值格式不一定就是人类熟悉的格式,必须理解底层二进制表示。

到这里,Windows 原生 ping 的调用路径已经基本清楚:ping.exe 使用 Win32 API 发送 ICMP Echo 消息;相关函数来自 IPHLPAPI.dll;用 dumpbin 可以静态查看依赖和导入函数;用 API Monitor 可以动态观察真实调用过程。


五、为什么接下来选择 Rust

如果完全按照 Windows 生态来写,最自然的选择可能是 C、C++ 或 C#。Win32 API 本来就以 C 接口形式存在,用 C 调用这些函数最直接;C++ 可以在此基础上做封装;C# 则可以通过 P/Invoke 调用系统库。

但这里选择 Rust,是因为目标不是复制 Microsoft 文档里的示例代码,而是借这个过程学习 Rust 如何和外部系统 API 交互。Rust 默认强调内存安全和类型安全,而 Win32 API 是典型的 C 世界接口:裸指针、空指针、手动约定、调用约定、动态库、函数地址。二者相遇时,很多边界问题会暴露出来。

在 Windows 上使用 Rust,需要注意工具链后缀。通过 rustup 安装 Rust 后,如果使用的是:

text 复制代码
stable-x86_64-pc-windows-msvc

那么最后的 msvc 表示它使用 MSVC 工具链,ABI 和 Visual Studio 2019 这类 Microsoft 工具链兼容。ABI 是 Application Binary Interface,应用二进制接口。它关系到函数调用时参数怎么传、返回值怎么放、栈怎么管理、符号怎么链接等底层约定。要和 Windows 系统库正确交互,ABI 兼容很重要。

新建一个 Rust 二进制项目后,可以用 cargo build 构建,再用 dumpbin /dependents 查看生成的 .exe 依赖哪些 DLL。一个最小 Rust 程序通常不会自动依赖 IPHLPAPI.dll。这意味着,如果要调用 ICMP 相关函数,有两条路可以走:一种是在构建阶段配置链接,让程序直接链接 IPHLPAPI.dll;另一种是在运行时通过系统 API 动态打开 IPHLPAPI.dll,再取出需要的函数地址。

这里选择第二种路线。它更绕,但能学到更多底层知识:Windows 如何动态加载 DLL,如何通过函数名找函数地址,Rust 如何声明外部函数,如何处理 C 字符串,如何把原始地址转换成可调用的函数指针。


六、用 KERNEL32.dll 动态加载库

一个最小 Rust 程序虽然不会链接 IPHLPAPI.dll,但通常会依赖 KERNEL32.dllKERNEL32.dll 提供了很多基础 Win32 API,其中就包括动态加载库相关函数。要在运行时打开一个 DLL,可以使用 LoadLibraryALoadLibraryW

Win32 API 里很多处理字符串的函数都有两个版本:AWA 通常表示 ANSI 版本,历史上接近 ASCII 或系统代码页;W 表示 Wide 字符版本,通常使用 UTF-16。现代 Windows 对字符编码有更多兼容行为,但理解 A/W 后缀仍然很重要。看到 LoadLibraryA,就应该知道它接收的是类似 C 字符串的窄字符指针;看到 LoadLibraryW,就应该想到 UTF-16 宽字符串。

如果用 Rust 来想象 LoadLibrary,最理想的函数签名可能是:

rust 复制代码
fn LoadLibrary(name: &str) -> Handle

但 Win32 API 不是 Rust 写的,它不认识 Rust 的 &str。C 世界里传字符串,常见方式是传一个指向字节序列的指针,并用空字节 \0 表示字符串结束。因此,LoadLibraryA 更接近下面的形式:

rust 复制代码
fn LoadLibraryA(name: *const u8) -> Handle

这里的 *const u8 是 Rust 的裸指针类型,表示指向不可变字节的原始指针。const 表示这个函数不应该修改传入的数据;u8 表示无符号 8 位整数,也就是一个字节。

不过,在 Rust 里调用外部函数,需要用 extern 声明。因为函数体不在当前 Rust 程序里,而是在某个外部动态库里。声明形式类似:

rust 复制代码
extern {
    fn LoadLibraryA(name: *const u8) -> Handle;
}

但这样还不完整,因为 Win32 API 有特定调用约定。


七、调用约定为什么重要

调用约定决定了函数调用时的底层细节:参数放在哪里,是放寄存器还是栈上;返回值放在哪里;函数调用结束后由调用方还是被调用方清理栈;不同大小的参数如何对齐;函数名如何修饰。这些东西平时写高级语言时很少直接接触,但一旦跨语言调用就非常重要。

如果调用约定写错,程序可能立刻崩溃,也可能看起来暂时能跑,但在某个诡异位置出现内存损坏、栈错乱或调试器无法解释的错误。更麻烦的是,调用约定错误不一定在调用点立刻暴露,可能会污染后续执行状态,让问题变得很难查。

Win32 API 常见调用约定是 stdcall。Rust 允许在 extern 声明中指定调用约定,因此应该写成:

rust 复制代码
extern "stdcall" {
    fn LoadLibraryA(name: *const u8) -> Handle;
}

这里的重点不是背下 stdcall 这个词,而是理解:跨语言调用时,函数签名不只是参数类型和返回值类型,还包括 ABI 和调用约定。Rust 编译器必须知道外部函数如何被调用,否则生成的机器码就可能和真实函数不匹配。

接下来还需要处理返回值类型,也就是前面签名里的 Handle


八、Windows 里的 handle 与 HMODULE

Windows API 里到处都是 handle。打开文件会得到 handle,创建窗口会得到 handle,打开进程会得到 handle,加载模块也会得到 handle。handle 可以理解为系统资源的某种引用。它不一定暴露真实内部结构,只是让调用方在后续 API 中用它代表某个资源。

LoadLibraryA 在 C 文档中的声明类似:

c 复制代码
HMODULE LoadLibraryA(
  LPCSTR lpLibFileName
);

这里的返回值是 HMODULEH 很多时候表示 handle,MODULE 表示模块,也就是加载进程地址空间里的 DLL 或 EXE 模块。LPCSTR 可以拆成 Long Pointer to Constant String,大概意思是指向常量 C 字符串的指针。

在 Rust 里,不知道 HMODULE 具体指向什么结构,也不应该随意解引用它。更安全的表达方式是把它当作不透明指针。Rust 标准库提供了 std::ffi::c_void,可以用来表示 C 里的 void。于是可以定义:

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

type HModule = *const c_void;

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

这段声明表达了几个关键信息:LoadLibraryA 是外部 Win32 函数,调用约定是 stdcall,参数是一个 C 字符串指针,返回值是一个不透明模块句柄。到这里,Rust 编译器已经知道如何生成调用这个函数的代码。

但真正调用它时,又会遇到 Rust 的安全边界。


九、调用外部函数为什么需要 unsafe

Rust 的安全模型无法保证外部 C 函数的行为。LoadLibraryA 不是 Rust 写的,Rust 编译器不知道它会不会读取越界内存,不知道传入指针是否有效,不知道它是否会保存这个指针,不知道它是否会修改全局状态,也不知道它是否遵守 Rust 的别名和生命周期规则。

因此,调用 extern 函数需要放在 unsafe 块里。unsafe 不是关闭所有检查,也不是让代码必然危险,而是告诉编译器:这里有一些 Rust 无法验证的前提,程序员需要自己保证它们成立。

第一次尝试可能会写成这样:

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

type HModule = *const c_void;

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

fn main() {
    let h = unsafe {
        LoadLibraryA("IPHLPAPI.dll".as_ptr())
    };

    println!("{:?}", h);
}

这段代码能表达大概意图:把 Rust 字符串 "IPHLPAPI.dll" 的底层指针传给 LoadLibraryA,然后打印返回的模块句柄。

但运行后可能会发现返回值是空指针,也就是 0x0。按照 Win32 API 的习惯,LoadLibraryA 返回 NULL 往往表示加载失败。到这里,表面上已经做了很多正确的事:找到了正确的 DLL 名称,声明了外部函数,指定了调用约定,使用了不透明指针类型,也放进了 unsafe 块。结果仍然失败,说明问题藏在更基础的位置。

问题出在字符串上。


十、Rust 字符串不是 C 字符串

传给 LoadLibraryA 的并不是一个"字符串对象",而只是一个内存地址。Rust 的字符串切片 &str 自己知道长度,但当调用 .as_ptr() 时,拿到的只是指向第一个字节的裸指针。这个指针本身不携带长度信息。

C 字符串的约定完全不同。C 函数通常不知道字符串长度,它会从传入地址开始一个字节一个字节往后读,直到遇到空字节 \0,才认为字符串结束。这就是 null-terminated string,也就是以空字节结尾的字符串。

因此,传入 "IPHLPAPI.dll".as_ptr() 并不等于传入一个合法 C 字符串。内存中这段字节后面不一定紧跟着 0LoadLibraryA 可能继续读取后面的随机内存,把额外字节也当成文件名的一部分。运气好时,它读到某个空字节后停止,但文件名已经不对,所以加载失败;运气不好时,它可能一直读到非法地址,造成访问违规或段错误。

修复方式很简单:手动给字符串加上 \0

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

    println!("{:?}", h);
}

加上空字节之后,LoadLibraryA 能正确识别 DLL 名称,返回值也不再是空指针。这是 Rust 调 Win32 API 时非常典型的第一课:Rust 字符串和 C 字符串不是同一种东西。只传裸指针时,长度信息会丢失;如果目标 API 期待 C 字符串,就必须保证字符串以 \0 结尾。

更正式、更不容易出错的写法通常会使用 CString 等类型,但这一篇为了暴露底层细节,直接用 "\0" 展示了问题本质。


十一、加载 DLL 之后,还要取函数地址

LoadLibraryA 只能把 DLL 加载进当前进程,并返回模块句柄。一个 DLL 里可以导出很多函数,真正调用某个函数之前,还需要根据函数名拿到它的地址。Windows 提供了 GetProcAddress

c 复制代码
FARPROC GetProcAddress(
  HMODULE hModule,
  LPCSTR  lpProcName
);

hModule 是前面 LoadLibraryA 返回的模块句柄,lpProcName 是函数名,同样是 C 字符串。返回值 FARPROC 可以理解为某个导出函数的地址。由于它可能指向任何签名的函数,所以在 Rust 里可以先把它表示成不透明指针:

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

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;
}

为了先验证这套机制,可以不急着调用 ICMP 相关函数,而是试一个更直观的 Win32 API:弹出消息框。消息框函数 MessageBoxA 位于 USER32.dll。先加载 USER32.dll,再从里面取出 MessageBoxA 的地址:

rust 复制代码
fn main() {
    unsafe {
        let h = LoadLibraryA("USER32.dll\0".as_ptr());
        let f = GetProcAddress(h, "MessageBoxA\0".as_ptr());

        println!("f = {:?}", f);
    }
}

如果打印出来的函数地址不是空指针,说明 DLL 加载成功,函数地址也找到了。但这还不意味着可以直接调用它。对 Rust 来说,f 只是一个裸指针,不是函数。裸指针不能像函数一样写 f(...) 调用。

要真正调用它,必须告诉 Rust:这个地址代表一个具有特定签名和调用约定的函数。


十二、MessageBoxA 的函数签名

MessageBoxA 的 C 声明大概是:

c 复制代码
int MessageBoxA(
  HWND   hWnd,
  LPCTSTR lpText,
  LPCTSTR lpCaption,
  UINT   uType
);

它用于弹出一个 Windows 消息框。hWnd 是父窗口句柄,可以传空指针表示没有父窗口;lpText 是消息正文;lpCaption 是标题;uType 是按钮和图标等配置,简单情况下可以传 0。

换成 Rust 函数指针类型,可以写成:

rust 复制代码
extern "stdcall" fn(*const c_void, *const u8, *const u8, u32)

为了可读性,可以定义类型别名:

rust 复制代码
type MessageBoxA = extern "stdcall" fn(
    *const c_void,
    *const u8,
    *const u8,
    u32,
);

这里仍然要注意调用约定。如果函数真实调用约定是 stdcall,Rust 里的函数指针类型也必须写成 extern "stdcall" fn(...),不能省略。

另外,空指针不能直接用整数 0 代替。Rust 标准库提供了 std::ptr::null(),可以用它创建一个空的不可变裸指针。正文和标题仍然要以 \0 结尾,因为 MessageBoxA 也期待 C 字符串。

理想上,可能想写成:

rust 复制代码
use std::{ffi::c_void, ptr::null};

type MessageBoxA = extern "stdcall" fn(
    *const c_void,
    *const u8,
    *const u8,
    u32,
);

fn main() {
    unsafe {
        let h = LoadLibraryA("USER32.dll\0".as_ptr());
        let f = GetProcAddress(h, "MessageBoxA\0".as_ptr());

        let MessageBoxA: MessageBoxA = f;

        MessageBoxA(
            null(),
            "Hello from Rust\0".as_ptr(),
            null(),
            0,
        );
    }
}

但这不会编译。原因也合理:f*const c_void,也就是一个原始地址;MessageBoxA 是一个函数指针类型。Rust 不会自动相信"这个地址就是这个签名的函数"。这种转换太危险,必须显式告诉编译器。

这时就轮到 transmute 出场。


十三、transmute:把一个值重新解释成另一种类型

std::mem::transmute 是 Rust 里非常强力也非常危险的工具。它可以把一个类型的值按原始位模式重新解释成另一个类型,而不做任何转换。也就是说,它不会检查这个地址是不是真的指向一个 MessageBoxA,不会检查函数签名是否匹配,也不会检查调用约定是否正确。它只是相信程序员的判断。

在这个场景里,GetProcAddress 返回一个原始函数地址,而我们知道这个函数名是 MessageBoxA,也知道它的 Win32 函数签名。因此,可以用 transmute 把原始地址转换为对应函数指针:

rust 复制代码
use std::{
    ffi::c_void,
    mem::transmute,
    ptr::null,
};

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

type MessageBoxA = extern "stdcall" fn(
    *const c_void,
    *const u8,
    *const u8,
    u32,
);

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

fn main() {
    unsafe {
        let h = LoadLibraryA("USER32.dll\0".as_ptr());

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

        MessageBoxA(
            null(),
            "Hello from Rust\0".as_ptr(),
            null(),
            0,
        );
    }
}

运行后,如果一切正确,会弹出一个来自 Rust 程序的 Windows 消息框。这说明几个底层能力已经打通:Rust 程序可以调用 KERNEL32.dll 中的 LoadLibraryA,可以动态加载 USER32.dll,可以通过 GetProcAddress 获取导出函数地址,可以把这个地址转换成函数指针,并最终按照 Win32 调用约定调用它。

这一步虽然只是弹了一个消息框,但它的意义很大。因为调用 MessageBoxA 和调用 IPHLPAPI.dll 里的 ICMP 函数,本质上是同一类问题:加载 DLL,取函数地址,定义正确的 Rust 函数指针类型,处理 C 字符串和结构体,放进 unsafe 边界里调用。


十四、这一篇真正建立了什么能力

表面上看,这一篇还没有发送真正的 ICMP Echo 请求,也没有实现完整 ping。它做的事情似乎只是研究 ping.exe、加载 DLL、弹出消息框。但从工程路径看,这一步非常必要。

首先,通过 dumpbin /dependentsdumpbin /imports,可以知道一个 Windows 可执行文件依赖哪些库、导入哪些函数。这是一种静态观察能力。它能帮助我们从现有程序反推出可能使用的系统 API。对 PING.EXE 来说,这一步定位到了 IcmpSendEcho2ExIcmp6SendEcho2IPHLPAPI.dll

其次,通过 API Monitor,可以看到真实程序如何调用系统 API。这是一种动态观察能力。静态导入表只能告诉你"程序可能调用这些函数",但监控运行时调用可以告诉你"它实际调用了什么、参数大概是什么"。对学习 Win32 API 来说,这比只读文档更直观。

再次,通过 Rust FFI,可以让 Rust 程序进入 Windows C API 世界。这个过程迫使我们面对很多平时容易忽略的底层细节:Rust 字符串和 C 字符串不同,裸指针没有长度信息,外部函数不受 Rust 内存安全保证,调用约定必须匹配,handle 可以用不透明指针表示,函数地址不能直接调用,transmute 必须放在 unsafe 里,并且使用时要非常确定目标类型正确。

最后,通过 LoadLibraryAGetProcAddress,可以在运行时动态加载 DLL,而不是在编译阶段静态链接。这种方式灵活,也更适合探索。后续如果要加载 IPHLPAPI.dll 并调用 IcmpSendEcho2Ex,整体流程已经基本清楚。


十五、几个容易踩坑的点

这一篇最值得记住的,不是某一段代码,而是几个跨语言调用时反复出现的坑。

第一个坑是字符串。Rust 的 &str 有长度信息,C 字符串靠 \0 结束。把 .as_ptr() 传给 C 函数时,传过去的只是地址,不是完整字符串对象。如果目标 API 期待 C 字符串,就必须保证结尾有空字节。否则函数可能读过界、读到垃圾数据,甚至触发访问错误。

第二个坑是调用约定。extern "stdcall" 不是语法装饰,而是 ABI 的一部分。调用约定错了,程序可能表现得非常诡异。跨语言调用时,必须让 Rust 的函数声明和真实外部函数在调用约定、参数类型、返回值类型上保持一致。

第三个坑是空指针和错误处理。Win32 API 很多函数用 NULL 表示失败,比如 LoadLibraryAGetProcAddress 返回空指针时,通常说明没有加载成功或没有找到函数。示例代码为了讲清主线,没有展开完整错误处理;实际工程里应该检查返回值,并调用 GetLastError 等方式获取错误原因。

第四个坑是 unsafe 的边界。unsafe 并不意味着代码一定错误,也不意味着 Rust 不再有价值。它的意义是把编译器无法证明安全的部分圈出来,让危险集中在少量地方。理想写法是用少量 unsafe 封装底层调用,再向外暴露安全的 Rust API。

第五个坑是 transmute。它很强,但几乎没有保护。把一个原始地址转换成函数指针时,必须保证地址非空、函数真实存在、签名正确、调用约定正确、生命周期合理。任何一个前提错了,都可能导致未定义行为或崩溃。能不用 transmute 时应尽量不用;必须用时,应该把前提写清楚,并把它限制在很小范围内。


十六、从消息框回到 ping

为什么要先调用 MessageBoxA,而不是直接调用 IcmpSendEcho2Ex?因为 MessageBoxA 更容易验证。它的效果很直观:调用成功就弹窗,调用失败就没有弹窗或崩溃。相比之下,ICMP 调用涉及更多结构体、缓冲区、网络权限、目标地址、返回数据解析,第一次上手时更难判断错误发生在哪一步。

先用 USER32.dllMessageBoxA 练习动态加载与函数调用,可以把问题拆小。只要能成功弹出消息框,就说明 FFI 的基础路径没问题。后续切换到 IPHLPAPI.dll,只需要把 DLL 名、函数名、函数签名、参数和返回值换成 ICMP 相关接口。

这也是学习底层系统编程时很有效的方法:不要一上来就挑战完整目标,而是先选一个可观察、可验证、依赖较少的小 API,把调用链路打通。等动态库加载、函数地址获取、调用约定、字符串、裸指针、unsafe 都跑通之后,再处理复杂业务逻辑。

在整个 "Making our own ping" 系列里,这一篇的地位就是铺路。第一篇回答"网络为什么长这样",第二篇回答"在 Windows 上,Rust 怎么调用系统 API"。后面真正发送 ping 请求时,就不需要再临时解释什么是 DLL、什么是 calling convention、为什么要 unsafe、为什么字符串要 \0 结尾、为什么要把地址转换成函数指针。


十七、总结

这一篇的主线可以概括成一句话:先研究 Windows 自带 ping.exe 如何工作,再让 Rust 具备调用同类 Win32 API 的能力。

具体过程是:用 Visual Studio 工具链里的 dumpbin 查看 PING.EXE 的依赖库和导入函数,找到 IcmpSendEcho2ExIcmp6SendEcho2,确认 ICMP 相关能力来自 IPHLPAPI.dll;再用 API Monitor 观察真实 ping.exe 调用过程,看到它会创建 ICMP handle、调用 IcmpSendEcho2Ex,并使用默认 TTL 等参数;随后转向 Rust,用 stable-x86_64-pc-windows-msvc 工具链保证 ABI 兼容;最后通过 LoadLibraryAGetProcAddressextern "stdcall"、裸指针、c_voidunsafetransmute,成功从 Rust 中动态加载 Windows DLL 并调用 MessageBoxA

这条路看起来绕,但它让后面的实现有了坚实基础。自己写 ping 并不只是了解 ICMP 包格式,还要知道操作系统提供了什么能力,用户态程序如何进入系统 API,动态库如何加载,函数地址如何变成可调用的函数指针,Rust 的安全边界又在哪里。

真正的底层编程经常不是"知道一个函数名就能调用",而是要把函数所在的库、ABI、调用约定、字符串格式、指针类型、错误返回和运行时加载方式全部对齐。只要这些底层规则中有一个错了,程序就可能失败、崩溃,或者更糟糕地在看似正常运行很久之后才暴露问题。

因此,这一篇虽然没有真正发出 ping 包,但已经完成了非常关键的一步:从 Windows 原生程序中找到 ICMP API 的线索,并用 Rust 打通调用 Win32 API 的底层通道。下一步,就可以把 MessageBoxA 换成 IPHLPAPI.dll 中的 ICMP 函数,真正开始构造和发送自己的 ping 请求。

相关推荐
Venuslite1 小时前
Mac系统安装Rust
rust
独隅1 小时前
IntelliJ IDEA 在 Windows 上的完整安装与使用指南
java·windows·intellij-idea
AI科技星2 小时前
数术宇宙:零一无穷创世史诗
开发语言·网络·量子计算·拓扑学
逻极2 小时前
Windows 平台 Ollama AMD GPU 一键编译指南:基于 ROCm 7.1 的自动化实战
人工智能·windows·stm32·自动化·gpu·amd·ollama
是多巴胺不是尼古丁2 小时前
期末java复习--string
java·开发语言·python
Survivor0012 小时前
高并发系统流量治理的底层算法
java·开发语言
郝学胜-神的一滴2 小时前
CMake 017:彩色日志输出实战
linux·c语言·开发语言·c++·软件工程·软件构建·cmake
m0_547486662 小时前
《数字图像处理:使用MATLAB分析与实现》全套课件PPT
开发语言·matlab·powerpoint
Full Stack Developme2 小时前
Apache Tika 教程
java·开发语言·python·apache