本文是对 Windows dynamic libraries, calling conventions, and transmute 的整理与翻译。
内容结构概览
- 从 ping.exe 开始逆向观察 :真正的
ping不会自己实现所有协议,而是调用 Windows 系统库。 - 用 dumpbin 查看依赖库 :通过 Visual Studio 工具链中的
dumpbin /dependents查看PING.EXE依赖哪些 DLL。 - 定位 ICMP 相关函数 :通过
dumpbin /imports和findstr找到IcmpSendEcho2Ex、Icmp6SendEcho2。 - 找到 IPHLPAPI.dll :ICMP 相关函数来自 IP Helper API,也就是
IPHLPAPI.dll。 - 用 API Monitor 观察真实调用 :通过监控
PING.EXE,看到创建 ICMP handle、调用IcmpSendEcho2Ex、默认 TTL 等信息。 - 转向 Rust 实现 :使用
stable-x86_64-pc-windows-msvc工具链,保证和 Visual Studio/MSVC ABI 兼容。 - 不直接链接 IPHLPAPI,而是运行时加载 :通过
KERNEL32.dll提供的LoadLibraryA和GetProcAddress动态打开 DLL、取函数地址。 - 理解 Win32 字符串和调用约定 :
A/W后缀、C 字符串、空字节结尾、stdcall调用约定。 - Rust FFI 基础 :
extern "stdcall"、裸指针、c_void、HMODULE、unsafe。 - 第一个坑:字符串没有
\0结尾:Rust 字符串切片不是 C 字符串,传给 Win32 API 时要手动补空字节。 - GetProcAddress 与函数指针:拿到函数地址后,不能直接当函数调用。
- transmute 登场 :把原始地址重新解释成函数指针,用来调用
MessageBoxA。 - 总结:这一篇不是直接实现 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 文件、dumpbin、IPHLPAPI.dll、LoadLibraryA、GetProcAddress、调用约定、C 字符串、裸指针、unsafe 和 transmute。这些东西看起来离 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
这里的 /dependents 是 dumpbin 的参数。Microsoft 命令行工具经常使用 /flag 这种风格,而不是 Unix 工具里常见的 --flag。
输出结果会列出一组 DLL。除了 msvcrt.dll、ntdll.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 表示大小写不敏感。这样可以同时匹配 ping、PING、Ping 等形式。
搜索 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.dll。KERNEL32.dll 提供了很多基础 Win32 API,其中就包括动态加载库相关函数。要在运行时打开一个 DLL,可以使用 LoadLibraryA 或 LoadLibraryW。
Win32 API 里很多处理字符串的函数都有两个版本:A 和 W。A 通常表示 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
);
这里的返回值是 HMODULE。H 很多时候表示 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 字符串。内存中这段字节后面不一定紧跟着 0。LoadLibraryA 可能继续读取后面的随机内存,把额外字节也当成文件名的一部分。运气好时,它读到某个空字节后停止,但文件名已经不对,所以加载失败;运气不好时,它可能一直读到非法地址,造成访问违规或段错误。
修复方式很简单:手动给字符串加上 \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 /dependents 和 dumpbin /imports,可以知道一个 Windows 可执行文件依赖哪些库、导入哪些函数。这是一种静态观察能力。它能帮助我们从现有程序反推出可能使用的系统 API。对 PING.EXE 来说,这一步定位到了 IcmpSendEcho2Ex、Icmp6SendEcho2 和 IPHLPAPI.dll。
其次,通过 API Monitor,可以看到真实程序如何调用系统 API。这是一种动态观察能力。静态导入表只能告诉你"程序可能调用这些函数",但监控运行时调用可以告诉你"它实际调用了什么、参数大概是什么"。对学习 Win32 API 来说,这比只读文档更直观。
再次,通过 Rust FFI,可以让 Rust 程序进入 Windows C API 世界。这个过程迫使我们面对很多平时容易忽略的底层细节:Rust 字符串和 C 字符串不同,裸指针没有长度信息,外部函数不受 Rust 内存安全保证,调用约定必须匹配,handle 可以用不透明指针表示,函数地址不能直接调用,transmute 必须放在 unsafe 里,并且使用时要非常确定目标类型正确。
最后,通过 LoadLibraryA 和 GetProcAddress,可以在运行时动态加载 DLL,而不是在编译阶段静态链接。这种方式灵活,也更适合探索。后续如果要加载 IPHLPAPI.dll 并调用 IcmpSendEcho2Ex,整体流程已经基本清楚。
十五、几个容易踩坑的点
这一篇最值得记住的,不是某一段代码,而是几个跨语言调用时反复出现的坑。
第一个坑是字符串。Rust 的 &str 有长度信息,C 字符串靠 \0 结束。把 .as_ptr() 传给 C 函数时,传过去的只是地址,不是完整字符串对象。如果目标 API 期待 C 字符串,就必须保证结尾有空字节。否则函数可能读过界、读到垃圾数据,甚至触发访问错误。
第二个坑是调用约定。extern "stdcall" 不是语法装饰,而是 ABI 的一部分。调用约定错了,程序可能表现得非常诡异。跨语言调用时,必须让 Rust 的函数声明和真实外部函数在调用约定、参数类型、返回值类型上保持一致。
第三个坑是空指针和错误处理。Win32 API 很多函数用 NULL 表示失败,比如 LoadLibraryA 或 GetProcAddress 返回空指针时,通常说明没有加载成功或没有找到函数。示例代码为了讲清主线,没有展开完整错误处理;实际工程里应该检查返回值,并调用 GetLastError 等方式获取错误原因。
第四个坑是 unsafe 的边界。unsafe 并不意味着代码一定错误,也不意味着 Rust 不再有价值。它的意义是把编译器无法证明安全的部分圈出来,让危险集中在少量地方。理想写法是用少量 unsafe 封装底层调用,再向外暴露安全的 Rust API。
第五个坑是 transmute。它很强,但几乎没有保护。把一个原始地址转换成函数指针时,必须保证地址非空、函数真实存在、签名正确、调用约定正确、生命周期合理。任何一个前提错了,都可能导致未定义行为或崩溃。能不用 transmute 时应尽量不用;必须用时,应该把前提写清楚,并把它限制在很小范围内。
十六、从消息框回到 ping
为什么要先调用 MessageBoxA,而不是直接调用 IcmpSendEcho2Ex?因为 MessageBoxA 更容易验证。它的效果很直观:调用成功就弹窗,调用失败就没有弹窗或崩溃。相比之下,ICMP 调用涉及更多结构体、缓冲区、网络权限、目标地址、返回数据解析,第一次上手时更难判断错误发生在哪一步。
先用 USER32.dll 和 MessageBoxA 练习动态加载与函数调用,可以把问题拆小。只要能成功弹出消息框,就说明 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 的依赖库和导入函数,找到 IcmpSendEcho2Ex、Icmp6SendEcho2,确认 ICMP 相关能力来自 IPHLPAPI.dll;再用 API Monitor 观察真实 ping.exe 调用过程,看到它会创建 ICMP handle、调用 IcmpSendEcho2Ex,并使用默认 TTL 等参数;随后转向 Rust,用 stable-x86_64-pc-windows-msvc 工具链保证 ABI 兼容;最后通过 LoadLibraryA、GetProcAddress、extern "stdcall"、裸指针、c_void、unsafe 和 transmute,成功从 Rust 中动态加载 Windows DLL 并调用 MessageBoxA。
这条路看起来绕,但它让后面的实现有了坚实基础。自己写 ping 并不只是了解 ICMP 包格式,还要知道操作系统提供了什么能力,用户态程序如何进入系统 API,动态库如何加载,函数地址如何变成可调用的函数指针,Rust 的安全边界又在哪里。
真正的底层编程经常不是"知道一个函数名就能调用",而是要把函数所在的库、ABI、调用约定、字符串格式、指针类型、错误返回和运行时加载方式全部对齐。只要这些底层规则中有一个错了,程序就可能失败、崩溃,或者更糟糕地在看似正常运行很久之后才暴露问题。
因此,这一篇虽然没有真正发出 ping 包,但已经完成了非常关键的一步:从 Windows 原生程序中找到 ICMP API 的线索,并用 Rust 打通调用 Win32 API 的底层通道。下一步,就可以把 MessageBoxA 换成 IPHLPAPI.dll 中的 ICMP 函数,真正开始构造和发送自己的 ping 请求。