本文是对 Designing and implementing a safer API on top of LoadLibrary 的整理与翻译。
内容结构概览
- 为什么要重构:上一节代码已经能 ping,但低层细节暴露太多。
- 设计
loadlibrary模块目标:不能忽略错误,不暴露 FFI 类型,不让调用方处理 C 字符串,不让调用方直接用transmute。 - 封装
Library类型:用私有字段隐藏 DLL handle。 - 用
NonNull表达非空 handle:把“成功打开的 DLL 一定有非空句柄”写进类型系统。 Library::new为什么返回Option<Self>:打开 DLL 可能失败,调用方必须处理。Option、match、expect、unwrap的语义:Rust 如何强迫处理失败分支。get_proc的 API 设计:函数查找也可能失败,并且返回类型应该由调用方决定。- 泛型返回值
get_proc<T>:不同符号可能对应不同函数签名。 - 为什么
get_proc仍然是 unsafe:类型T是否正确,编译器无法验证。 - 实现
Library::new:CString、LoadLibraryA、Option<NonNull<c_void>>、Option::map。 - Rust 字符串与 C 字符串的区别:不是所有 Rust 字符串都能直接作为 C 字符串传给 Win32 API。
- 实现
get_proc:GetProcAddress、CString、Option、transmute_copy。 - 回到 ping 主程序:用新封装替换原来散乱的 unsafe 调用。
- 总结:这篇真正讲的是 Rust API 设计,而不只是 Win32 动态加载。
上一篇已经真正发出了 ICMP Echo 请求。Rust 程序通过 IPHLPAPI.dll 拿到 IcmpCreateFile 和 IcmpSendEcho,创建 ICMP handle,构造请求数据和响应缓冲区,最后解析出 ICMP_ECHO_REPLY。从功能角度看,自己的 ping 已经跑通了;但从代码质量角度看,问题也很明显:main() 里混杂了太多低层细节。
在同一个文件里,既有 LoadLibraryA 和 GetProcAddress 的声明,也有 Win32 ICMP API 的声明,还有真正 ping 8.8.8.8 的业务代码。更糟糕的是,main() 直接处理 C 字符串、裸指针、动态库句柄、函数地址转换和 transmute。这意味着主程序不仅要知道“我要调用 ICMP API”,还要知道“DLL 怎么加载、符号怎么查找、函数地址怎么转成函数指针、字符串为什么要以 \0 结尾”。这不是一个好接口。
这一篇的目标不是继续增加 ping 功能,而是先重构动态库加载这部分代码。核心思路是:把 LoadLibraryA 和 GetProcAddress 包装成一个更安全、更符合 Rust 习惯的模块。外部调用者只需要拿到一个 Library,再通过名字查找函数;至于 Win32 句柄、C 字符串、空指针、transmute 等细节,都尽量收束在模块内部。
这正是系统编程里很重要的一步。能把 unsafe 调通只是第一阶段,把 unsafe 边界封装好才是第二阶段。Rust 的价值不是让底层危险消失,而是让危险集中在少数地方,并用类型系统把能表达的约束表达出来。
一、为什么需要重构
上一节完整程序大约一百行左右,依赖很少,除了调试输出用到的 pretty-hex,基本没有额外包。对于一个实验程序来说,这已经不错。但问题在于关注点混在一起。主程序既负责业务逻辑,也负责 FFI 绑定,还负责动态库加载。这样的代码能跑,但很难维护。
更好的结构应该把不同层次分开。比如,动态加载 DLL 和查找函数地址可以放进一个 loadlibrary 模块;Win32 ICMP API 的类型声明可以放进另一个模块;真正的命令行逻辑和 ping 逻辑再放在主程序里。这样每个模块只关心自己的问题,修改某一层时不会牵动所有代码。
这一篇先处理第一层:LoadLibraryA 和 GetProcAddress。这两个函数是 Windows 动态库加载的基础。LoadLibraryA 根据 DLL 名称把动态库加载进当前进程,返回模块句柄;GetProcAddress 根据模块句柄和函数名找到导出函数地址。上一节直接在 main() 里调用它们,并手动把函数地址 transmute 成具体函数指针。这种写法非常暴露底层细节。
重构目标可以明确成几条:调用方不能轻易忽略错误;调用方不需要接触 FFI/C 类型;调用方不需要手动处理 C 风格空字节结尾字符串;调用方不需要直接使用 transmute。这些要求合起来,就是要把危险的 Win32 FFI 操作包成一个更窄、更清楚的 Rust API。
二、先设计公开接口,而不是急着实现
写底层封装时,很容易一开始就从 Win32 函数签名出发,把 C API 机械翻译成 Rust。比如 LoadLibraryA 接收 *const u8 或 *const c_char,返回 *const c_void;GetProcAddress 接收一个模块指针和函数名指针,返回一个函数地址指针。这样写当然能工作,但它只是“C API 的 Rust 语法版本”,并没有真正变成 Rust 风格接口。
更好的做法是先设计外部调用方应该看到什么。调用方真正关心的是“打开一个 DLL”和“从 DLL 里取出一个函数”。因此,可以先定义一个公开类型:
pub struct Library {
handle: *const c_void,
}
这里 Library 是公开的,但字段 handle 是私有的。这样外部代码能持有 Library,却不能直接读写内部 handle。这已经比裸指针好很多,因为动态库句柄不再到处传播,只有 loadlibrary 模块内部知道它的真实形态。
还可以进一步把 handle 类型做成私有别名:
use std::ffi::c_void;
type HModule = *const c_void;
pub struct Library {
handle: HModule,
}
这个变化表面上很小,但语义更清楚。HModule 表示 Windows 模块句柄,它只是模块内部实现细节,不需要暴露给外部。外部调用者只需要知道 Library 表示一个已经打开的 DLL。
不过,这里还有一个问题。*const c_void 可以是空指针,而成功打开的 DLL 句柄不应该为空。也就是说,当前类型并没有表达“只要你拿到了 Library,它里面的 handle 就一定有效”。如果结构体里仍然存裸指针,理论上仍然可以构造出一个内部 handle 为 null 的 Library。虽然字段是私有的,外部不能直接乱构造,但模块内部仍然没有把这个不变量表达清楚。
Rust 标准库里有一个非常适合这里的类型:NonNull<T>。
三、用 NonNull 表达非空指针
NonNull<T> 是一个表示非空裸指针的类型。普通裸指针 *const T 或 *mut T 可以为 null,而 NonNull<T> 在类型层面表达了“这里不应该是空指针”。如果一个 Library 表示已经成功打开的 DLL,那么它内部的模块句柄就应该用 NonNull<c_void> 表示:
use std::{
ffi::c_void,
ptr::NonNull,
};
type HModule = NonNull<c_void>;
pub struct Library {
handle: HModule,
}
这样,类型本身就承载了一个重要约束:只要有一个 HModule,它就不是空指针。当然,这个保证不是凭空来的。FFI 函数声明必须写对,创建 Library 时也必须正确处理 LoadLibraryA 可能返回 null 的情况。NonNull 不是魔法,它只是让不变量可以被类型表达出来。
这也是 Rust 封装 C API 时常见的思路。C API 往往用裸指针表达很多状态:空指针表示失败,非空指针表示成功。Rust 里不应该继续把这些状态混在一个裸指针里,而应该把它拆成 Option<NonNull<T>>。None 表示失败或没有值,Some(non_null) 表示成功且非空。这样调用方必须显式处理失败分支,不会无意间拿着空指针继续往下走。
四、Library::new 应该返回什么
打开动态库是一个可能失败的操作。DLL 可能不存在,路径可能不对,权限可能不足,依赖可能缺失,系统也可能拒绝加载。因此,Library::new("USER32.dll") 不能简单返回 Library。如果签名写成:
impl Library {
pub fn new(name: &str) -> Self {
unimplemented!()
}
}
那失败时就很尴尬。要么直接 panic,要么返回一个内部无效的 Library。这两个都不好。loadlibrary 是一个库模块,库代码不应该随便替应用程序决定要不要崩溃;返回无效实例也会破坏刚才用 NonNull 建立的语义。
Rust 中表达可能失败的操作,通常用 Result<T, E>。如果要完整保留错误原因,可以定义:
pub enum Error {
LibraryNotFound,
}
impl Library {
pub fn new(name: &str) -> Result<Self, Error> {
unimplemented!()
}
}
不过这一篇为了简化,不深入调用 GetLastError,也不区分具体失败原因。对于当前目标来说,只关心两种状态:库打开成功,或者没有打开成功。因此可以用 Option<Self>:
impl Library {
pub fn new(name: &str) -> Option<Self> {
unimplemented!()
}
}
这个签名非常重要。它告诉调用方:你不一定能拿到 Library,必须处理 None。如果直接写:
let lib = Library::new("USER32.dll");
lib.get_proc("MessageBoxA");
编译器会报错,因为 Library::new 返回的是 Option<Library>,而不是 Library。Option<Library> 上没有 get_proc 方法。调用方必须先把 Option 处理掉,才能得到真正的 Library。
可以用 match:
let lib = match Library::new("USER32.dll") {
Some(lib) => lib,
None => panic!("could not open USER32.dll"),
};
也可以用 expect:
let lib = Library::new("USER32.dll")
.expect("could not open USER32.dll");
或者临时偷懒用 unwrap:
let lib = Library::new("USER32.dll").unwrap();
这些方式的区别在于失败时如何处理。match 可以自定义完整分支逻辑,expect 会在 None 时 panic 并输出自定义消息,unwrap 也会 panic 但信息更少。不管选择哪一种,都有一点是确定的:调用方不能假装失败不存在。类型系统会把这件事推到代码表面。
五、get_proc 也会失败
打开 DLL 之后,下一步是查找函数地址。底层 Win32 API 是 GetProcAddress。这个操作同样可能失败,因为 DLL 里不一定导出了指定名称的函数。比如在 USER32.dll 里查 MessageBoxA 可能成功,查一个不存在的 FooBar 就会失败。因此,get_proc 也应该返回 Option。
一开始可以设计成:
impl Library {
pub unsafe fn get_proc(&self, name: &str) -> Option<Proc> {
unimplemented!()
}
}
但这里马上出现一个新问题:Proc 到底是什么类型?GetProcAddress 返回的是一个地址,但这个地址可能代表任何函数。MessageBoxA 是一种签名,ShowWindow 是另一种签名,IcmpCreateFile 又是另一种签名。loadlibrary 模块不知道调用者想拿哪个函数,也不知道这个函数应该有怎样的参数和返回值。
因此,不能在模块内部定义一个固定的 Proc。返回类型应该由调用方决定。Rust 可以用泛型表达这一点:
impl Library {
pub unsafe fn get_proc<T>(&self, name: &str) -> Option<T> {
unimplemented!()
}
}
这个 API 表达的意思是:给定函数名,尝试从当前 DLL 中取出一个符号,并把它解释成调用方指定的类型 T。比如调用方可以写:
type MessageBoxA = extern "stdcall" fn(
*const c_void,
*const c_char,
*const c_char,
u32,
) -> i32;
let message_box: MessageBoxA = unsafe {
user32.get_proc("MessageBoxA").unwrap()
};
也可以写:
type IcmpCreateFile = extern "stdcall" fn() -> Handle;
let icmp_create_file: IcmpCreateFile = unsafe {
iphlp.get_proc("IcmpCreateFile").unwrap()
};
不同调用点的 T 可以不同。Rust 的类型推断会根据赋值目标推断 T 是什么。如果上下文没有足够信息,编译器会要求你写出类型。这一点很好,因为动态符号查找本来就需要明确函数签名。你不能只拿一个地址就随便调用,必须告诉编译器它是什么函数。
六、为什么 get_proc 仍然必须是 unsafe
看起来我们已经把 GetProcAddress 包装得更高级了:调用方传 Rust 字符串,返回 Option<T>,不需要直接接触 C 字符串,也不需要直接写 transmute。那 get_proc 能不能做成安全函数?
不能。
原因在于 T 是否正确,编译器无法验证。假设 MessageBoxA 的真实签名是:
extern "stdcall" fn(hwnd, text, caption, flags) -> i32
但调用方硬要把它写成:
extern "stdcall" fn() -> i32
从 loadlibrary 模块内部看,根本无法判断这个类型是否匹配真实 Win32 函数。GetProcAddress 只根据字符串返回一个地址,不携带 Rust 类型信息。把这个地址解释成什么函数指针,完全依赖调用方提供的 T。
一旦 T 错了,程序可能立刻崩溃,也可能破坏栈、寄存器、参数传递和返回值解释,造成难以排查的问题。这里存在一个必须由调用方保证的不变量:函数名和类型 T 必须匹配真实导出函数的 ABI、调用约定、参数和返回值。编译器不能替你证明这一点,所以 get_proc<T> 必须标记为 unsafe。
这体现了 Rust 里 unsafe 的真正含义。unsafe 不是说这个函数一定有 bug,而是说调用它需要满足一些编译器无法检查的前提。把 get_proc 标成 unsafe,就是要求调用方在调用点显式承认:“我知道这个符号对应的真实函数签名,并且我保证类型写对了。”
七、实现 Library::new:先处理字符串
设计好接口后,开始实现。底层还是调用 LoadLibraryA。A 后缀表示它接收窄字符 C 字符串,也就是以空字节结尾的字符串指针。Rust 的 &str 不能直接传给它。上一节已经踩过这个坑:"IPHLPAPI.dll".as_ptr() 只是指向 UTF-8 字节开头的指针,不带长度,也不保证后面有 \0。C 函数会一直往后读,直到碰到空字节为止,这可能读到垃圾数据,也可能读到非法内存。
一种做法是手动构造 Vec<u8>:
let mut c_name = Vec::new();
c_name.extend_from_slice(name.as_bytes());
c_name.push(0);
这能工作,但这类操作太常见,标准库已经提供了更合适的类型:CString。CString 是拥有所有权的 C 字符串,它会负责分配内存,并保证末尾有空字节。它还会检查字符串内部不能包含空字节,因为 C 字符串以第一个 \0 作为结束,如果中间出现空字节,传给 C API 时就会被截断。
使用 CString 时,需要注意它的 as_ptr() 返回的是 *const c_char,而不是 *const u8。在 Windows/MSVC 目标上,c_char 通常对应 i8。因此,LoadLibraryA 的 Rust 声明也要调整:
use std::os::raw::c_char;
extern "stdcall" {
fn LoadLibraryA(name: *const c_char) -> Option<HModule>;
}
这里返回值写成 Option<HModule>,其中 HModule = NonNull<c_void>。这是一个很 Rust 的表达:底层 Win32 API 实际返回的是一个可能为空的模块句柄;Rust 侧把它建模为 Option<NonNull<c_void>>。如果返回 null,就是 None;如果返回非空,就是 Some(HModule)。
这样 Library::new 可以写得很清楚:
use std::{
ffi::{c_void, CString},
os::raw::c_char,
ptr::NonNull,
};
type HModule = NonNull<c_void>;
extern "stdcall" {
fn LoadLibraryA(name: *const c_char) -> Option<HModule>;
}
pub struct Library {
module: HModule,
}
impl Library {
pub fn new(name: &str) -> Option<Self> {
let name = CString::new(name).expect("invalid library name");
let res = unsafe { LoadLibraryA(name.as_ptr()) };
res.map(|module| Library { module })
}
}
最后一行的 map 很值得注意。res 是 Option<HModule>,但函数要返回 Option<Library>。如果 res 是 None,map 会保持 None;如果 res 是 Some(module),map 会把里面的 module 转成 Library { module }。这比手写 match 更简洁:
match res {
Some(module) => Some(Library { module }),
None => None,
}
两种写法语义一样,map 更适合这种“只转换成功值,失败值原样保留”的场景。
八、Rust 字符串和 C 字符串不是同一种东西
这一篇中间花了不少篇幅解释 C 字符串和 Rust 字符串的差别,这是 FFI 编程中非常基础但很容易出错的点。
Rust 的 &str 是一段有效 UTF-8 字节序列的借用,它知道自己的长度。即使字符串里包含 \0,Rust 也可以正常保存,因为 \0 对 Rust 字符串来说只是一个普通字节。C 字符串则完全不同,它通常只是一段内存的起始指针,长度靠第一个空字节决定。C 函数不知道字符串真实长度,只能从指针位置开始一直读,直到遇到 0。
这就导致两个方向的转换都不简单。从 C 字符串转 Rust 字符串时,需要先找到空字节,得到长度,然后用 from_raw_parts 构造字节切片,再用 std::str::from_utf8 检查它是不是有效 UTF-8。这个过程非常危险,因为 Rust 借用检查器并不知道这个 &str 实际引用的是哪块外部内存。如果那块内存被释放,Rust 里的 &str 就变成悬垂引用。
从 Rust 字符串转 C 字符串时,也不是简单 .as_ptr()。必须分配一段拥有所有权的内存,把 Rust 字符串内容复制进去,再追加空字节,并保证这段内存在 C 函数使用期间一直有效。CString 正是为了这个场景准备的。
因此,loadlibrary 的封装不应该让外部调用方自己手写 "\0"。对外 API 接收 &str,内部用 CString::new 转成 C 字符串。这样调用方写:
Library::new("IPHLPAPI.dll")
而不是:
LoadLibraryA("IPHLPAPI.dll\0".as_ptr())
这就是封装的意义。它不只是少写几行代码,而是把一类容易出错的约定固定在模块内部。
九、实现 get_proc
get_proc 的实现和 new 很像。底层调用 GetProcAddress,它接收一个模块句柄和一个函数名 C 字符串,返回一个可能为空的函数地址。可以先定义:
type FarProc = NonNull<c_void>;
extern "stdcall" {
fn GetProcAddress(
module: HModule,
name: *const c_char,
) -> Option<FarProc>;
}
这里同样用 Option<FarProc> 表达可能失败。FarProc 用 NonNull<c_void> 表达非空函数地址。因为 Library 里保存的 module 已经是 NonNull<c_void>,所以传给 GetProcAddress 时不会再传空模块句柄。
get_proc 的完整实现大致如下:
use std::{
ffi::{c_void, CString},
mem::transmute_copy,
os::raw::c_char,
ptr::NonNull,
};
type HModule = NonNull<c_void>;
type FarProc = NonNull<c_void>;
extern "stdcall" {
fn LoadLibraryA(name: *const c_char) -> Option<HModule>;
fn GetProcAddress(
module: HModule,
name: *const c_char,
) -> Option<FarProc>;
}
pub struct Library {
module: HModule,
}
impl Library {
pub fn new(name: &str) -> Option<Self> {
let name = CString::new(name).expect("invalid library name");
let res = unsafe { LoadLibraryA(name.as_ptr()) };
res.map(|module| Library { module })
}
pub unsafe fn get_proc<T>(&self, name: &str) -> Option<T> {
let name = CString::new(name).expect("invalid proc name");
let res = GetProcAddress(self.module, name.as_ptr());
res.map(|proc| transmute_copy(&proc))
}
}
这里用了 transmute_copy,而不是上一节常见的 transmute。它会从源值复制出一份目标类型的位模式。由于 get_proc<T> 是泛型返回,普通 transmute 在这个场景下不容易直接写出想要的形式,因此这里使用 transmute_copy 把 FarProc 重新解释成调用方指定的函数指针类型 T。
这一步仍然非常危险。transmute_copy 不会检查 T 是不是函数指针,不会检查大小是否合理,不会检查调用约定是否匹配,也不会检查函数名是否真的对应这个签名。所有这些都由调用方保证。因此,虽然 get_proc 把 transmute 从 main() 里藏起来了,但它并没有把危险变没,只是把危险集中到了一个小函数里,并通过 unsafe fn 把责任边界明确标出来。
十、用新封装替换主程序里的底层代码
重构前,主程序里会有类似这样的代码:
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()));
}
这段代码的问题很明显。第一,main() 需要知道 C 字符串必须以 \0 结尾。第二,main() 直接处理 DLL handle。第三,main() 直接调用 GetProcAddress。第四,main() 直接使用 transmute。第五,错误处理也不明显,如果 DLL 加载失败或者函数不存在,很容易继续拿着空指针往下走。
使用 loadlibrary::Library 之后,主程序可以变成:
let iphlp = loadlibrary::Library::new("IPHLPAPI.dll")
.expect("could not open IPHLPAPI.dll");
let IcmpCreateFile: IcmpCreateFile = unsafe {
iphlp.get_proc("IcmpCreateFile").unwrap()
};
let IcmpSendEcho: IcmpSendEcho = unsafe {
iphlp.get_proc("IcmpSendEcho").unwrap()
};
对比之后可以看到,主程序仍然需要在 get_proc 处写 unsafe,因为函数签名是否正确仍然需要调用方保证。但其他很多低层细节已经消失了:不需要写 \0,不需要手动调用 LoadLibraryA,不需要手动调用 GetProcAddress,不需要在业务代码里出现 transmute,也不需要直接处理 Win32 原始句柄。
这就是一次有效的重构。它不是把所有 unsafe 都消灭掉,而是把 unsafe 的范围缩小,把重复的、易错的、与业务无关的细节放进一个模块里。主程序仍然知道自己在做一件危险的事:把一个动态符号解释成某个函数指针。但它不再需要知道实现这件事所需的所有机械细节。
十一、API 设计比“能跑”更重要
这一篇最值得注意的地方,不是 LoadLibraryA 或 GetProcAddress 的具体写法,而是它展示了 Rust 中 API 设计的思路。先不要急着实现,而是先问:外部调用方应该看到什么?哪些细节应该隐藏?哪些错误应该强制处理?哪些不变量可以用类型系统表达?哪些危险必须保留为 unsafe?
比如,Library 类型隐藏了模块句柄,防止外部代码随便读写 handle。NonNull 表达了“成功打开的模块句柄不应该为空”。Library::new 返回 Option<Self>,迫使调用方处理 DLL 加载失败。get_proc<T> 返回 Option<T>,迫使调用方处理函数查找失败。CString 把 Rust 字符串转换成 C 字符串的细节藏在模块内部。unsafe fn get_proc<T> 则明确告诉调用方:虽然接口变简单了,但函数签名匹配这件事仍然需要你自己负责。
这里有一个很重要的边界感。不是所有东西都能包装成安全 API。比如 Library::new 可以是安全的,因为传入一个普通 Rust 字符串,最多就是 DLL 加载失败,函数返回 None。但 get_proc<T> 不能安全,因为它让调用方把一个无类型地址解释成任意类型 T。如果 T 写错,后果可能是未定义行为。这个前提无法由 loadlibrary 模块验证,所以必须保留 unsafe。
这才是 Rust 中封装 unsafe 的正确方式:能检查的就交给类型系统,不能检查的就用 unsafe 显式标出来,并尽量缩小危险范围。
十二、Option 在这里不只是语法糖
在这篇重构里,Option 出现得很多。Library::new 返回 Option<Library>,LoadLibraryA 声明成返回 Option<HModule>,GetProcAddress 声明成返回 Option<FarProc>,get_proc<T> 也返回 Option<T>。这不是为了“写得 Rust 一点”,而是在把 Win32 API 的空指针语义重新建模。
C API 常见模式是:返回一个指针,成功时非空,失败时 null。C 语言本身不会强制你检查 null。你可以直接拿着返回值继续用,直到某个地方崩溃。Rust 里可以把这种模式转换成 Option<NonNull<T>>。这样一来,失败状态不再是一种“特殊指针值”,而是类型的一部分。
Option::map 也不是花哨写法。它恰好适合“如果有值,就转换里面的值;如果没有值,就保持没有值”的场景。LoadLibraryA 返回 Option<HModule>,而 Library::new 要返回 Option<Library>。成功时把 HModule 包成 Library,失败时保持 None,这正是 map 的语义。
这类小设计会让代码整体更可靠。不是因为 Option 能解决所有问题,而是它让“可能失败”无法被默默忽略。只要你想拿到里面的值,就必须选择一种处理方式:match、if let、expect、unwrap,或者在返回 Option/Result 的函数里用 ? 继续向上传递。无论哪一种,失败分支都会出现在代码结构里。
十三、unimplemented!() 的作用
在设计 API 的过程中,可以先写出函数签名,再用 unimplemented!() 占位。比如:
impl Library {
pub fn new(name: &str) -> Option<Self> {
unimplemented!()
}
pub unsafe fn get_proc<T>(&self, name: &str) -> Option<T> {
unimplemented!()
}
}
这样做的好处是,先让类型和接口参与编译。即使函数体还没实现,其他代码也可以开始围绕这个接口编写。等真正运行到这段函数时,程序会 panic,并告诉你还有代码没有实现。这相当于一个运行时强制的 TODO。
在重构过程中,这种做法很实用。因为 API 设计和实现细节可以分开推进。先确定 new 应该接收 &str,返回 Option<Self>;再确定 get_proc<T> 应该接收 &self 和 &str,返回 Option<T>,并且是 unsafe。等这些签名稳定之后,再回头处理 CString、LoadLibraryA、GetProcAddress、NonNull、transmute_copy 等具体实现。
这也是写 Rust 时很常见的开发方式。类型系统很强,函数签名本身就包含很多设计信息。先把签名写好,编译器会不断提醒哪里不匹配,哪些错误分支没处理,哪些类型不一致。API 设计不是文档里的抽象讨论,而是可以通过编译器不断验证的工程过程。
十四、这一篇对 ping 项目的意义
从功能上看,这一篇并没有让 ping 更强。它没有增加域名解析,没有支持 IPv6,没有改进输出格式,也没有处理统计信息。程序仍然只是调用 IcmpCreateFile 和 IcmpSendEcho,向目标地址发送请求并读取响应。
但从代码结构上看,这一步非常关键。上一节的程序能跑,是因为所有底层操作都直接堆在 main() 里。这样的代码继续往下写,很快会失控。后面如果要支持更多 Win32 API、更多平台、更多参数和更完整错误处理,必须先把基础设施封装起来。
loadlibrary 模块完成了一个小而重要的边界划分:动态库加载和符号查找不再属于 ping 业务逻辑。主程序只需要知道“我要打开 IPHLPAPI.dll,我要拿到 IcmpCreateFile 和 IcmpSendEcho”。至于怎么把 Rust 字符串变成 C 字符串,怎么处理 null,怎么从函数地址变成函数指针,都放在模块内部。
这样的封装还能带来后续收益。如果将来想改成返回 Result 并携带 GetLastError,只需要改 loadlibrary 模块。如果想支持 LoadLibraryW 和 UTF-16 宽字符串,也可以在模块内部改。如果想把 transmute_copy 换成更清晰的实现,也不会影响主程序大部分逻辑。
这就是重构的价值:当前功能没有变,但未来修改成本下降了。
十五、还存在的问题
这一版 loadlibrary 已经比直接在 main() 里写 Win32 调用好很多,但它还不是完美封装。
首先,它用 Option 表示失败,但没有保留具体错误原因。打开 DLL 失败可能有很多原因,函数查找失败也可能有不同原因。真实库最好返回 Result<T, Error>,并通过 GetLastError 获取 Windows 错误码,再把它转换成可读错误信息。这里选择 Option 是为了让主线更简单。
其次,CString::new(name).expect("invalid library name") 在字符串内部包含空字节时会 panic。对示例代码来说这可以接受,因为 DLL 名和函数名通常是静态字符串。但如果是库代码,可能更适合把这个错误也纳入 Result,而不是直接 panic。
再次,get_proc<T> 使用 transmute_copy,这是一把很锋利的刀。它依赖调用方提供正确的 T。如果函数签名、调用约定、参数类型或返回类型写错,编译器无法保护你。虽然 unsafe 已经标出了责任边界,但库文档应该明确说明这个前提。
最后,动态库生命周期也还没有完整处理。Library 持有 DLL handle,但还没有在 Drop 里调用 FreeLibrary 释放它。对于一个短命令行程序来说,进程退出时系统会回收资源;但如果要写成长生命周期库,就应该处理资源释放。
这些问题并不削弱这一篇的价值。相反,它们说明封装是逐步推进的。第一步先让主程序不再直接面对所有底层细节;后续再继续增加错误类型、资源释放、文档和安全约束。
十六、总结
这一篇把重点从“怎么发出 ping”转向“怎么把底层调用封装成更好的 Rust API”。上一节已经证明 Rust 可以通过 Win32 ICMP API 发包,但代码还停留在实验阶段:main() 里到处是 C 字符串、裸指针、动态库句柄、GetProcAddress 和 transmute。这一篇通过新建 loadlibrary 模块,把这些细节集中起来。
最终形成的接口很简单:Library::new("IPHLPAPI.dll") 尝试打开动态库,返回 Option<Library>;library.get_proc::<T>("IcmpSendEcho") 尝试查找函数,返回 Option<T>。内部则用 CString 处理 C 字符串,用 NonNull<c_void> 表达非空句柄,用 Option 表达 Win32 API 的 null 返回,用 transmute_copy 把函数地址转换成调用方指定的类型。
这套设计体现了 Rust 封装 FFI 的核心思路:把 C API 的松散约定尽量翻译成 Rust 类型。空指针变成 Option,有效句柄变成 NonNull,C 字符串转换隐藏在模块内部,失败必须由调用方处理,无法由编译器验证的函数签名匹配则保留为 unsafe。
真正好的封装不是把危险伪装成安全,而是明确区分哪些危险可以消除,哪些危险只能隔离。LoadLibraryA 和 GetProcAddress 本身仍然是底层 Win32 API,函数地址转换仍然需要调用方保证类型正确。但经过这一层封装,主程序已经不需要关心大部分机械细节。它可以把注意力重新放回 ping 本身:创建 ICMP handle、发送 Echo 请求、解析 Echo Reply、处理超时和输出结果。
从这个角度看,这一篇虽然没有增加新功能,却让整个项目向“可维护程序”迈了一步。能跑只是开始,能把 unsafe 收束起来,能让错误处理进入类型系统,能让主程序不被底层细节污染,才是 Rust 系统编程真正有价值的部分。