本文是对 Binding C APIs with variable-length structs and UTF-16 的整理与翻译。
内容结构概览
- 为什么抛弃 WMI:上一节用 WMI 找默认网卡,但 WMI 更偏系统管理,不适合作为底层网络程序的核心依赖。
- 回到 Win32 API :用
GetIpForwardTable查询 IPv4 路由表,用GetInterfaceInfo查询接口信息。 - 先用 C 程序验证思路:按 Win32 API 的典型模式,第一次传空指针获取所需缓冲区大小,第二次分配内存再真正查询。
- 从路由表找默认接口 :在
MIB_IPFORWARDTABLE中寻找0.0.0.0默认路由,拿到dwForwardIfIndex。 - 从接口表找 GUID :在
IP_INTERFACE_INFO/IP_ADAPTER_INDEX_MAP中,根据接口 index 找到适配器 name,其中包含网卡 GUID。 - 改造
loadlibrary宏 :把第 6 篇里写死IPHLPAPI.dll的宏改成可指定 DLL 名称的通用宏。 - 绑定
GetIpForwardTable:开始在 Rust 中定义MIB_IPFORWARDTABLE与MIB_IPFORWARDROW。 - 变长 C 结构体的问题 :C 里
table[ANY_SIZE]或Adapter[1]这种结构,在 Rust 里不能简单放到栈上。 - 为什么
MaybeUninit不够:问题不是"未初始化",而是"结构体实际大小要运行时才能知道"。 - 用
Vec<u8>承载原始内存:先用空指针查询大小,再分配字节缓冲区,最后把这段内存解释成 C 结构体。 - 引入
VLS<T>:用泛型容器把Vec<u8>和它对应的 C 结构体视图绑在一起,避免悬垂引用。 - 实现
Deref:让VLS<IpForwardTable>可以像IpForwardTable一样读取字段。 - 实现
entries()与adapters():从固定长度为 1 的数组起点,通过slice::from_raw_parts得到真实长度的切片。 - 补齐结构体布局 :
IpForwardRow不能只写关心的字段,必须用_other_fields占位,保证每一行大小正确。 - 处理 IPv4 地址显示 :复用
ipv4::Addr,让dest、mask、next_hop从整数变成0.0.0.0这种可读形式。 - 处理 UTF-16 :
IP_ADAPTER_INDEX_MAP.Name是WCHAR[128],在 Rust 里用[u16; 128]表示,并用String::from_utf16_lossy转成字符串。 - 最终得到默认网卡 GUID :用默认路由找到接口 index,再用接口表找到对应 name,从其中截取
{GUID},拼成 Npcap 接口名。
上一篇已经解决了一个关键问题:如果要自己发送原始网络数据包,就必须先找到默认网络接口。系统里可能有有线网卡、无线网卡、VPN、虚拟机网卡、环回接口和各种 WAN 适配器,不能靠猜,也不能靠接口列表顺序。上一节用 WMI 查询 Win32_IP4RouteTable 和 Win32_NetworkAdapter,先从默认路由 0.0.0.0 找到 InterfaceIndex,再通过网卡信息找到 GUID,最后拼出 Npcap 能识别的接口名。
这条路能跑,但并不理想。WMI 更像是系统管理员查询系统信息的工具,而不是底层网络程序应该依赖的核心接口。它有查询语言,有 COM,有反序列化,有额外 crate,整个路径显得有点重。既然我们已经知道 Windows 里有 IPv4 路由表,也知道网络接口有 index,那么更直接的做法应该是调用 Win32 API 本身,而不是通过 WMI 间接查询。
这一篇就把上一节的 WMI 方案替换掉,改用 IPHLPAPI.dll 里的两个函数:GetIpForwardTable 和 GetInterfaceInfo。前者能拿到 IPv4 路由表,从里面找默认路由;后者能拿到接口 index 和接口 name,从 name 中得到网卡 GUID。听起来很直接,但真正麻烦的地方不在函数名,而在 C API 返回的数据结构:它们都是典型的"变长结构体",也就是结构体尾部带一个长度不固定的数组。Rust 对这种 C 结构体不能直接照搬,需要用一段原始字节内存承载数据,再用类型系统尽量把危险限制住。
一、为什么要放弃 WMI 方案
上一节的 WMI 方案是这样的:先查询 Win32_IP4RouteTable,找到 Destination = 0.0.0.0 的默认路由,拿到 InterfaceIndex;再查询 Win32_NetworkAdapter,用这个 InterfaceIndex 找到对应网卡的 GUID;最后把 GUID 拼成 Npcap 接口名:
text
\Device\NPF_{GUID}
这个方案的优点是清楚、好验证,而且 WMI 查询结果足够结构化。缺点也很明显:依赖 wmi、serde、maplit 等额外 crate,路径比较绕,而且 WMI 本质上更偏系统管理场景。我们要写的是一个接近网络协议栈底层的程序,继续依赖查询语言和 WMI 管理接口,会让实现显得不够直接。
既然已经知道需要的信息来自 IPv4 路由表和网络接口表,就应该找有没有对应的 Win32 API。搜索 "Win32 API IPv4 routing table" 能找到 GetIpForwardTable。这个函数位于 IPHLPAPI.dll,而这个 DLL 前面已经很熟悉了:前几篇用 Windows ICMP API 实现 ping 时,IcmpCreateFile、IcmpSendEcho、IcmpCloseHandle 也都来自这里。
GetIpForwardTable 的签名大致如下:
c
IPHLPAPI_DLL_LINKAGE DWORD GetIpForwardTable(
PMIB_IPFORWARDTABLE pIpForwardTable,
PULONG pdwSize,
BOOL bOrder
);
它返回的是完整 IPv4 路由表。路由表里有多行,每一行是 MIB_IPFORWARDROW,其中包含目标地址、掩码、下一跳、接口 index 等信息。默认路由的目标地址是 0.0.0.0,也就是整数 0。因此,只要遍历路由表,找到 dwForwardDest == 0 的行,就能拿到默认接口 index。
有了接口 index 后,还需要拿到这个接口对应的 GUID。IPHLPAPI.dll 里还有 GetInterfaceInfo:
c
IPHLPAPI_DLL_LINKAGE DWORD GetInterfaceInfo(
PIP_INTERFACE_INFO pIfTable,
PULONG dwOutBufLen
);
它返回的是接口表。每个接口项是 IP_ADAPTER_INDEX_MAP,里面有 Index 和 Name。在 Windows Vista 及之后,Name 可能是一个包含网络接口 GUID 的 Unicode 字符串,例如:
text
\DEVICE\TCPIP_{0E89380B-814A-48FC-86C4-5C51B8040CB2}
这就和上一节 WMI 查到的 GUID 对上了。也就是说,完全可以不通过 WMI,而是直接用 GetIpForwardTable 和 GetInterfaceInfo 获得同样结果。
二、先用一个 C 程序验证 Win32 API 路线
在 Rust 里绑定复杂 C API 之前,先写一个很小的 C 程序验证思路,是非常务实的做法。C 程序更贴近 Microsoft 文档里的函数签名,少了 Rust FFI、所有权、生命周期、结构体布局等额外变量。如果 C 版本都跑不通,Rust 版本大概率也会走偏。
先包含需要的头文件:
c
#include <windows.h>
#include <iphlpapi.h>
#include <ipmib.h>
#include <stdio.h>
int main() {
// C 代码先在这里验证 API 行为
return 0;
}
GetIpForwardTable 的使用方式很典型:第一次调用时传空指针,让系统告诉我们需要多大的缓冲区;如果返回 ERROR_INSUFFICIENT_BUFFER,说明缓冲区确实不够,但 pdwSize 已经被写成需要的大小;然后再分配这么大的内存,第二次调用真正获取数据。
第一步是查询所需大小:
c
DWORD err;
ULONG table_size = 0;
err = GetIpForwardTable(NULL, &table_size, FALSE);
if (err == ERROR_INSUFFICIENT_BUFFER) {
// 正常,继续分配 buffer
} else {
fprintf(stderr, "GetIpForwardTable returned: %x\n", err);
return 1;
}
printf("table_size = %d\n", table_size);
编译时需要链接 iphlpapi.lib:
text
cl.exe getiface.c /link iphlpapi.lib
运行后可以看到类似:
text
table_size = 2196
这说明函数确实按预期返回了所需大小。接下来用 calloc 分配足够空间,再调用第二次:
c
PMIB_IPFORWARDTABLE table = calloc(table_size, 1);
err = GetIpForwardTable(table, &table_size, FALSE);
if (err == NO_ERROR) {
// 正常
} else {
fprintf(stderr, "GetIpForwardTable (second) returned: %x\n", err);
return 1;
}
printf("Num entries: %d\n", table->dwNumEntries);
这里用 calloc,因为它会把分配出来的内存清零。这个清零不一定严格必要,但在性能不敏感的实验代码里,清零可以减少一些不必要的不确定性。运行后可能看到:
text
Num entries: 21
这说明路由表已经拿到了。接下来找默认路由:
c
int ifaceIndex = -1;
for (int i = 0; i < table->dwNumEntries; i++) {
if (0 == table->table[i].dwForwardDest) {
ifaceIndex = table->table[i].dwForwardIfIndex;
break;
}
}
if (ifaceIndex == -1) {
fprintf(stderr, "Default interface not found");
return 1;
}
printf("Default interface index = %d\n", ifaceIndex);
IPv4 地址在这里就是一个 DWORD。0.0.0.0 四个字节全是 0,所以无论字节序如何,整体值都是 0。找到 dwForwardDest == 0 的路由项后,就能拿到 dwForwardIfIndex,例如输出:
text
Default interface index = 5
这和上一节通过 WMI 查到的默认接口 index 一致,说明第一半路已经打通。
三、再用 GetInterfaceInfo 从 index 找 GUID
接下来要把接口 index 转成网卡 GUID。GetInterfaceInfo 的调用模式和 GetIpForwardTable 很像,也是先传空指针查询所需缓冲区大小,再分配内存后第二次调用。
C 结构体大致如下:
c
typedef struct _IP_INTERFACE_INFO {
LONG NumAdapters;
IP_ADAPTER_INDEX_MAP Adapter[1];
} IP_INTERFACE_INFO, *PIP_INTERFACE_INFO;
其中 IP_ADAPTER_INDEX_MAP 是:
c
typedef struct _IP_ADAPTER_INDEX_MAP {
ULONG Index;
WCHAR Name[MAX_ADAPTER_NAME];
} IP_ADAPTER_INDEX_MAP, *PIP_ADAPTER_INDEX_MAP;
它包含两个核心字段:Index 和 Name。Index 应该对应前面路由表里拿到的接口 index;Name 是一个宽字符数组,也就是 Windows 常见的 UTF-16 字符串。文档说明,在较新的 Windows 上,Name 可能是网络接口的 GUID 字符串,开头通常包含 \DEVICE\TCPIP_{...}。
C 代码里可以这样调用:
c
err = GetInterfaceInfo(NULL, &table_size);
if (err == ERROR_INSUFFICIENT_BUFFER) {
// 正常,继续分配
} else {
fprintf(stderr, "GetInterfaceInfo returned: %x\n", err);
return 1;
}
PIP_INTERFACE_INFO ifaces = calloc(table_size, 1);
err = GetInterfaceInfo(ifaces, &table_size);
if (err == NO_ERROR) {
// 正常
} else {
fprintf(stderr, "GetInterfaceInfo (second) returned: %x\n", err);
return 1;
}
printf("Listed %d ifaces\n", ifaces->NumAdapters);
for (int i = 0; i < ifaces->NumAdapters; i++) {
if (ifaces->Adapter[i].Index == ifaceIndex) {
printf("Found it! Name is: %S", ifaces->Adapter[i].Name);
}
}
如果一切顺利,可以看到类似:
text
Listed 7 ifaces
Found it! Name is: \DEVICE\TCPIP_{0E89380B-814A-48FC-86C4-5C51B8040CB2}
这证明通过 Win32 API 可以拿到和 WMI 一样的 GUID。现在真正的问题变成:如何在 Rust 里安全地绑定这两个 API,尤其是如何处理它们返回的变长 C 结构体和 UTF-16 字符串。
四、把 loadlibrary 宏变成可复用版本
前面已经写过一个 loadlibrary 模块,用于包装 LoadLibraryA 和 GetProcAddress。第 6 篇还写过一个宏,用来绑定 IPHLPAPI.dll 里的函数,并用 once_cell::sync::Lazy 缓存函数指针。不过那个宏当时写死了 "IPHLPAPI.dll",只能服务于 sup 项目里的 ICMP 函数。
现在 ersatz 项目也需要绑定 IPHLPAPI.dll,可以把 loadlibrary.rs 从 sup 复制过来,再把宏改成更通用的形式,让调用方在宏里指定 DLL 名称:
rust
#[macro_export]
macro_rules! bind {
(
library $lib:expr;
$(fn $name:ident($($arg:ident: $type:ty),*) -> $ret:ty;)*
) => {
struct Functions {
$(
pub $name: extern "stdcall" fn(
$($arg: $type),*
) -> $ret
),*
}
static FUNCTIONS: once_cell::sync::Lazy<Functions> =
once_cell::sync::Lazy::new(|| {
let lib = crate::loadlibrary::Library::new($lib).unwrap();
Functions {
$(
$name: unsafe {
lib.get_proc(stringify!($name)).unwrap()
}
),*
}
});
$(
#[inline(always)]
pub fn $name($($arg: $type),*) -> $ret {
(FUNCTIONS.$name)($($arg),*)
}
)*
};
}
现在可以在 netinfo.rs 里这样使用:
rust
crate::bind! {
library "IPHLPAPI.dll";
fn IcmpCreateFile() -> *const std::ffi::c_void;
}
先绑定一个已知简单函数来验证宏能不能工作。如果调用 IcmpCreateFile() 能得到非空指针,说明宏、LoadLibrary、GetProcAddress 和函数指针缓存这条路径没有问题。等这条基础路径跑通,再绑定真正需要的 GetIpForwardTable 和 GetInterfaceInfo。
五、C 的变长结构体在 Rust 里不好直接表达
真正麻烦从 MIB_IPFORWARDTABLE 开始。C 结构体定义大致是:
c
typedef struct _MIB_IPFORWARDTABLE {
DWORD dwNumEntries;
MIB_IPFORWARDROW table[ANY_SIZE];
} MIB_IPFORWARDTABLE, *PMIB_IPFORWARDTABLE;
这里的 table[ANY_SIZE] 是 C 里常见的变长结构体技巧。结构体头部有一个数量字段,尾部跟着一个数组。数组真实长度不是编译期固定的,而是运行时由系统返回的数据决定。内存里实际布局类似:
text
+----------------+
| dwNumEntries |
+----------------+
| row[0] |
+----------------+
| row[1] |
+----------------+
| row[2] |
+----------------+
| ... |
+----------------+
Rust 可以写出一个类似的动态大小类型:
rust
#[repr(C)]
#[derive(Debug)]
pub struct IpForwardTable {
num_entries: u32,
entries: [IpForwardRow],
}
#[repr(C)]
#[derive(Debug)]
pub struct IpForwardRow {}
这从类型概念上说是合理的:entries 是一个动态大小切片。但问题是,这个类型的大小在编译期不可知。局部变量必须有静态已知大小,所以不能直接在栈上写:
rust
let table: IpForwardTable;
编译器会报错,因为 [IpForwardRow] 没有编译期已知大小。对于 Rust 来说,这变成了 DST,也就是 Dynamically-Sized Type。DST 在某些场景下很有用,比如 str、[T]、trait object,但这种 C 风格"结构体头部 + 变长数组尾部"的场景在稳定 Rust 中并不顺手。
一个临时想法是把尾部数组写成长度为 1:
rust
#[repr(C)]
#[derive(Debug)]
pub struct IpForwardTable {
num_entries: u32,
entries: [IpForwardRow; 1],
}
这模拟了很多 C 头文件里的写法:结构体声明里放一个一元素数组,但实际分配的内存比结构体本身更大,后面继续跟着更多元素。这种写法可以让 Rust 类型本身有固定大小,但它不代表真实数据只有一行。后续读取时还要根据 num_entries 手动构造切片。
不过即使这样,也不能直接写:
rust
let mut table: IpForwardTable;
let mut size = std::mem::size_of_val(&table) as u32;
因为 table 尚未初始化,Rust 不允许借用一个可能未初始化的局部变量。也许可以想到 MaybeUninit,但很快会发现,MaybeUninit<IpForwardTable> 只能解决"未初始化"问题,不能解决"实际结构体比类型声明更大"的问题。
六、为什么 MaybeUninit 不是答案
可以尝试这样写:
rust
use std::mem::{self, MaybeUninit};
let mut table: MaybeUninit<IpForwardTable> = MaybeUninit::uninit();
let mut size = mem::size_of_val(&table) as u32;
GetIpForwardTable(table.as_mut_ptr(), &mut size, false);
let table = unsafe { table.assume_init() };
dbg!(&table);
这段代码的问题有两个。
第一个问题是没有检查 GetIpForwardTable 的返回值。如果传入的缓冲区太小,它不会写出完整结果,而是返回 ERROR_INSUFFICIENT_BUFFER。如果忽略返回值,再调用 assume_init(),就会把没有正确初始化的内存当成合法结构体读取,打印出来的 num_entries 可能是一个荒谬数字。
第二个问题更根本:即使检查返回值,也没法把一个固定大小的 MaybeUninit<IpForwardTable> "变大"。GetIpForwardTable 需要的空间可能是几 KB,因为里面有很多路由项。MaybeUninit<IpForwardTable> 只给了"结构体头 + 一个 row"的空间,根本不够。C 里解决这个问题的方式是先查需要多少字节,再分配这么多字节的原始内存,而不是分配一个固定大小的结构体。
当返回值显示 ERROR_INSUFFICIENT_BUFFER,错误码 122,也就是数据区太小时,这其实正是预期行为。正确流程应该是:
text
第一次调用:buffer = NULL,size = 0
返回 ERROR_INSUFFICIENT_BUFFER,并写出需要的 size
第二次调用:分配 size 字节 buffer
返回 NO_ERROR,buffer 中包含完整数据
因此,问题应该反过来处理:不要先创建一个 IpForwardTable,再试图让它变大;而是先分配一段原始字节内存,再把这段内存解释成 IpForwardTable。
七、用 Vec<u8> 作为原始内存
Rust 里当然可以使用 std::alloc::alloc 手动分配原始内存,但这需要自己管理 Layout,还要记得 dealloc,容易出错。这里用 Vec<u8> 更简单。Vec 负责分配和释放内存,as_mut_ptr() 可以拿到可写指针,正好可以传给 Win32 API。
流程可以写成:
rust
use std::{mem, ptr};
let mut size = 0;
match GetIpForwardTable(ptr::null_mut(), &mut size, false) {
ERROR_INSUFFICIENT_BUFFER => {
// 正常,继续
}
ret => return Err(Error::Win32(ret)),
}
let mut v = vec![0u8; size as usize];
match GetIpForwardTable(
unsafe { mem::transmute(v.as_mut_ptr()) },
&mut size,
false,
) {
0 => {
// 正常
}
ret => return Err(Error::Win32(ret)),
}
let table: &IpForwardTable = unsafe {
mem::transmute(v.as_ptr())
};
dbg!(table.num_entries);
这段代码能工作,能打印出类似 table.num_entries = 21。但它的安全性非常脆弱。table 只是对 v 内部字节的一种视图。真正拥有内存的是 v,如果 v 被释放,table 就变成悬垂引用。Rust 类型系统在这段手写 transmute 后,并不知道 table 的生命周期应该和 v 绑定起来。
例如下面这种代码就非常危险:
rust
let table: &IpForwardTable = unsafe {
mem::transmute(v.as_ptr())
};
drop(v);
dbg!(table.num_entries);
从 Rust 的借用规则角度看,如果写法绕过了类型系统,编译器未必能阻止这种错误。运行时读到的可能就是已经释放或未初始化的内存。这个问题说明:仅仅用 Vec<u8> 承载原始内存还不够,还需要一个类型把"拥有数据的 Vec<u8>"和"对这段数据的结构体视图"绑定起来。
八、引入 VLS<T>:变长结构体容器
这里可以抽象出一个通用容器,暂时叫 VLS<T>,意思是 variable-length struct。它内部拥有一段 Vec<u8>,同时在类型层面表示"这段字节可以被解释成某个 C 结构体 T"。
先定义:
rust
use std::marker::PhantomData;
pub struct VLS<T> {
v: Vec<u8>,
_phantom: PhantomData<T>,
}
为什么需要 PhantomData<T>?因为 VLS<T> 这个类型参数 T 并没有真正作为字段存储。如果只写:
rust
pub struct VLS<T> {
v: Vec<u8>,
}
编译器会警告 T 没有被使用。PhantomData<T> 的作用就是告诉编译器:这个结构体在类型语义上和 T 有关系,即使运行时没有真的存一个 T。在 FFI、所有权标记、生命周期建模中,PhantomData 非常常见。
接下来希望 VLS<T> 能像 T 一样访问字段,比如 vls.num_entries。可以实现 Deref:
rust
use std::{mem, ops::Deref};
impl<T> Deref for VLS<T> {
type Target = T;
fn deref(&self) -> &T {
unsafe { mem::transmute(self.v.as_ptr()) }
}
}
这里仍然有 unsafe,因为 Rust 不能自动确认这段字节确实是一个合法的 T。但这次 unsafe 被封装在 VLS 内部。更重要的是,返回的 &T 的生命周期和 &self 绑定,也就是不能比 VLS<T> 本身活得更久。
这样,下面的错误会被 Rust 编译器阻止:
rust
let vls = get_vls::<IpForwardTable>();
let table: &IpForwardTable = &*vls;
drop(vls);
dbg!(table);
因为 table 是从 vls 借出来的,table 还在使用时不能移动或 drop vls。这正是 VLS<T> 的价值:它不能让所有 unsafe 消失,但至少把原始内存和结构体视图的生命周期关系交回给 Rust 借用检查器。
九、让 VLS::new 统一处理两次 Win32 调用
GetIpForwardTable 和后面的 GetInterfaceInfo 都遵循同一个模式:
text
第一次调用:传空 buffer,拿到 ERROR_INSUFFICIENT_BUFFER 和所需 size
第二次调用:分配 size 字节 buffer,再真正获取数据
既然模式一样,可以让 VLS<T> 提供一个通用构造函数:
rust
use crate::error::Error;
impl<T> VLS<T> {
pub fn new<F>(f: F) -> Result<Self, Error>
where
F: Fn(*mut T, *mut u32) -> u32,
{
unimplemented!()
}
}
调用方式希望是:
rust
let table = VLS::new(|ptr, size| {
GetIpForwardTable(ptr, size, false)
})?;
这里的闭包接收两个参数:一个指向 T 的可变指针,一个指向 size 的可变指针,返回 Win32 的错误码。VLS::new 不关心具体函数是 GetIpForwardTable 还是 GetInterfaceInfo,只关心它是否遵循这套"先查大小,再填缓冲区"的调用协议。
实现如下:
rust
use std::{mem, ptr};
use std::marker::PhantomData;
const ERROR_INSUFFICIENT_BUFFER: u32 = 122;
impl<T> VLS<T> {
pub fn new<F>(f: F) -> Result<Self, Error>
where
F: Fn(*mut T, *mut u32) -> u32,
{
let mut size = 0;
match f(ptr::null_mut(), &mut size) {
ERROR_INSUFFICIENT_BUFFER => {
// 正常,继续
}
ret => return Err(Error::Win32(ret)),
};
let mut v = vec![0u8; size as usize];
match f(unsafe { mem::transmute(v.as_mut_ptr()) }, &mut size) {
0 => {
// 正常
}
ret => return Err(Error::Win32(ret)),
};
Ok(Self {
v,
_phantom: PhantomData::default(),
})
}
}
这段代码把最危险、最容易重复写错的部分集中起来。以后只要有类似 Win32 变长结构体 API,都可以用 VLS::new。它先用空指针探测大小,再分配 Vec<u8>,然后第二次调用填充数据,最后返回一个拥有这段字节的 VLS<T>。
这里并不是完全安全。VLS::new 仍然假设传入的函数真的会按约定写入一个合法的 T,也假设返回 0 代表成功,122 代表缓冲区不足。它还通过 transmute 把 Vec<u8> 的指针转成 *mut T。但相比每个 API 手写一遍,这个抽象至少减少了重复 unsafe 代码,也让生命周期关系更可控。
十、从 IpForwardTable 里取出真实路由项
现在可以这样拿路由表:
rust
let table = VLS::new(|ptr, size| {
GetIpForwardTable(ptr, size, false)
})?;
dbg!(table.num_entries);
但 IpForwardTable 结构体里目前的 entries 是 [IpForwardRow; 1],只是为了模拟 C 的变长尾部。真实路由表有 num_entries 行,需要从第一行地址开始构造一个切片:
rust
use std::slice;
impl IpForwardTable {
fn entries(&self) -> &[IpForwardRow] {
unsafe {
slice::from_raw_parts(
&self.entries[0],
self.num_entries as usize,
)
}
}
}
这段代码本质上是在告诉 Rust:虽然类型里只写了一个元素,但这块内存后面实际上还有 num_entries 个连续的 IpForwardRow。这个信息来自 Win32 API 返回的数据。Rust 编译器自己不能验证,所以需要 unsafe。
一开始 IpForwardRow 可能还是空结构体:
rust
#[repr(C)]
#[derive(Debug)]
pub struct IpForwardRow {}
这样打印 entries() 会看到很多空行,没有实际信息。必须把 C 的 MIB_IPFORWARDROW 布局补出来。完整 C 结构体字段很多:
c
typedef struct _MIB_IPFORWARDROW {
DWORD dwForwardDest;
DWORD dwForwardMask;
DWORD dwForwardPolicy;
DWORD dwForwardNextHop;
IF_INDEX dwForwardIfIndex;
union { DWORD dwForwardType; MIB_IPFORWARD_TYPE ForwardType; };
union { DWORD dwForwardProto; MIB_IPFORWARD_PROTO ForwardProto; };
DWORD dwForwardAge;
DWORD dwForwardNextHopAS;
DWORD dwForwardMetric1;
DWORD dwForwardMetric2;
DWORD dwForwardMetric3;
DWORD dwForwardMetric4;
DWORD dwForwardMetric5;
} MIB_IPFORWARDROW, *PMIB_IPFORWARDROW;
当前只关心目标地址和接口 index,但结构体大小必须正确。如果只写关心的几个字段,那么第一行可能还能读对,第二行开始就会错位,因为 Rust 会以错误的结构体大小计算下一行位置。因此,必须把不关心的字段也占上空间:
rust
#[repr(C)]
#[derive(Debug)]
pub struct IpForwardRow {
dest: u32,
mask: u32,
policy: u32,
next_hop: u32,
if_index: u32,
_other_fields: [u32; 9],
}
_other_fields 的作用不是业务逻辑,而是保证 IpForwardRow 的内存大小与 C 结构体一致。系统编程里这点非常重要:你可以不关心某些字段的语义,但不能忽略它们占用的字节。
为了调试输出不被 _other_fields 干扰,可以使用 custom_debug_derive,跳过这个字段:
rust
use custom_debug_derive::Debug;
#[repr(C)]
#[derive(Debug)]
pub struct IpForwardRow {
dest: u32,
mask: u32,
policy: u32,
next_hop: u32,
if_index: u32,
#[debug(skip)]
_other_fields: [u32; 9],
}
这样打印时就能看到核心字段,而不会被一长串无关整数淹没。
十一、让 IPv4 地址显示成正常形式
如果 dest、mask、next_hop 都是 u32,打印出来会很不友好:
text
dest: 0
mask: 0
next_hop: 4261521600
前面 sup 项目里已经写过一个 IPv4 地址 newtype,可以在 ersatz 里也加一个 ipv4 模块:
rust
use std::fmt;
#[derive(PartialEq, Eq, Clone, Copy)]
pub struct Addr(pub [u8; 4]);
impl fmt::Display for Addr {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let [a, b, c, d] = self.0;
write!(f, "{}.{}.{}.{}", a, b, c, d)
}
}
impl fmt::Debug for Addr {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self)
}
}
然后把 IpForwardRow 改成:
rust
#[repr(C)]
#[derive(Debug)]
pub struct IpForwardRow {
dest: ipv4::Addr,
mask: ipv4::Addr,
policy: u32,
next_hop: ipv4::Addr,
if_index: u32,
#[debug(skip)]
_other_fields: [u32; 9],
}
这样打印路由表就更直观:
text
IpForwardRow {
dest: 0.0.0.0,
mask: 0.0.0.0,
policy: 0,
next_hop: 192.168.1.254,
if_index: 5,
}
现在可以很自然地找到默认路由:
rust
let entry = table
.entries()
.iter()
.find(|r| r.dest == ipv4::Addr([0, 0, 0, 0]))
.expect("should have default interface");
ipv4::Addr 派生了 PartialEq 和 Eq,所以可以直接比较。找到默认路由后,就拿到了 entry.if_index,接下来用它在接口表中查对应接口。
十二、绑定 GetInterfaceInfo
现在还需要绑定第二个函数:
rust
crate::bind! {
library "IPHLPAPI.dll";
fn GetIpForwardTable(
table: *mut IpForwardTable,
size: *mut u32,
order: bool
) -> u32;
fn GetInterfaceInfo(
info: *mut IpInterfaceInfo,
size: *mut u32
) -> u32;
}
GetInterfaceInfo 返回的结构体是 IP_INTERFACE_INFO:
c
typedef struct _IP_INTERFACE_INFO {
LONG NumAdapters;
IP_ADAPTER_INDEX_MAP Adapter[1];
} IP_INTERFACE_INFO, *PIP_INTERFACE_INFO;
Rust 里同样用一个长度为 1 的数组模拟变长尾部:
rust
#[repr(C)]
#[derive(Debug)]
pub struct IpInterfaceInfo {
num_adapters: u32,
adapter: [IpAdapterIndexMap; 1],
}
再给它写一个 adapters() 方法,和前面的 entries() 方法一样:
rust
impl IpInterfaceInfo {
fn adapters(&self) -> &[IpAdapterIndexMap] {
unsafe {
slice::from_raw_parts(
&self.adapter[0],
self.num_adapters as usize,
)
}
}
}
然后就可以查询接口表:
rust
let ifaces = VLS::new(|ptr, size| {
GetInterfaceInfo(ptr, size)
})?;
let iface = ifaces
.adapters()
.iter()
.find(|r| r.index == entry.if_index)
.expect("default interface should exist");
现在只差一个关键点:IP_ADAPTER_INDEX_MAP 里的 Name 是 WCHAR[MAX_ADAPTER_NAME],也就是 UTF-16 宽字符数组。前面几篇一直尽量使用 A 后缀的 Win32 API,避开了 UTF-16;这里避不开了。
十三、Windows 的 WCHAR 和 UTF-16
IP_ADAPTER_INDEX_MAP 的 C 定义是:
c
typedef struct _IP_ADAPTER_INDEX_MAP {
ULONG Index;
WCHAR Name[MAX_ADAPTER_NAME];
} IP_ADAPTER_INDEX_MAP, *PIP_ADAPTER_INDEX_MAP;
WCHAR 在 Windows 上基本可以看成 16 位宽字符,对应 Rust 里的 u16。MAX_ADAPTER_NAME 在这里是 128,因此可以把 Name 表示成 [u16; 128]。为了让这个字段更有语义,可以再包一个 newtype:
rust
pub struct IpAdapterName([u16; 128]);
然后实现 Display:
rust
use std::fmt;
impl fmt::Display for IpAdapterName {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let s = String::from_utf16_lossy(&self.0[..]);
write!(f, "{}", s.trim_end_matches("\0"))
}
}
这里用 String::from_utf16_lossy 把 UTF-16 转成 Rust 的 UTF-8 String。lossy 的意思是如果遇到非法 UTF-16 序列,会用替代字符处理,而不是返回错误。在这个场景里,数据来自 Windows API,通常可以假设它是有效的接口名。
为什么要 trim_end_matches("\0")?因为 Name 是固定长度 128 的数组,而真实字符串可能远远短于 128。C 风格宽字符串会以空宽字符结尾,后面剩余位置也可能是 0。转成 Rust 字符串后,尾部会有很多 \0,必须去掉。
为了让 IpAdapterIndexMap 能 derive(Debug),还要给 IpAdapterName 实现 Debug。可以直接复用 Display:
rust
impl fmt::Debug for IpAdapterName {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
然后定义 IpAdapterIndexMap:
rust
#[repr(C)]
#[derive(Debug)]
pub struct IpAdapterIndexMap {
index: u32,
name: IpAdapterName,
}
现在打印接口项就能看到可读的 name:
text
IpAdapterIndexMap {
index: 5,
name: \DEVICE\TCPIP_{0E89380B-814A-48FC-86C4-5C51B8040CB2},
}
这说明 UTF-16 处理成功了。
十四、最终实现 default_nic_guid
现在所有部件都齐了。default_nic_guid() 可以分成几步。
第一步,获取路由表:
rust
let table = VLS::new(|ptr, size| {
GetIpForwardTable(ptr, size, false)
})?;
第二步,从路由表找默认路由:
rust
let entry = table
.entries()
.iter()
.find(|r| r.dest == ipv4::Addr([0, 0, 0, 0]))
.expect("should have default interface");
第三步,获取接口表:
rust
let ifaces = VLS::new(|ptr, size| {
GetInterfaceInfo(ptr, size)
})?;
第四步,根据默认路由的 if_index 找对应接口:
rust
let iface = ifaces
.adapters()
.iter()
.find(|r| r.index == entry.if_index)
.expect("default interface should exist");
第五步,从接口 name 中截取 GUID。接口 name 形如:
text
\DEVICE\TCPIP_{0E89380B-814A-48FC-86C4-5C51B8040CB2}
需要从第一个 { 开始截取:
rust
let name = iface.name.to_string();
let guid_start = name.find("{").expect("interface name should have a guid");
let guid = &name[guid_start..];
Ok(guid.to_string())
完整 netinfo.rs 接近下面这样:
rust
#![allow(non_snake_case)]
use crate::error::Error;
use crate::ipv4;
use crate::vls::VLS;
use custom_debug_derive::*;
use std::fmt;
use std::slice;
pub fn default_nic_guid() -> Result<String, Error> {
let table = VLS::new(|ptr, size| {
GetIpForwardTable(ptr, size, false)
})?;
let entry = table
.entries()
.iter()
.find(|r| r.dest == ipv4::Addr([0, 0, 0, 0]))
.expect("should have default interface");
let ifaces = VLS::new(|ptr, size| {
GetInterfaceInfo(ptr, size)
})?;
let iface = ifaces
.adapters()
.iter()
.find(|r| r.index == entry.if_index)
.expect("default interface should exist");
let name = iface.name.to_string();
let guid_start = name.find("{").expect("interface name should have a guid");
let guid = &name[guid_start..];
Ok(guid.to_string())
}
#[repr(C)]
#[derive(Debug)]
pub struct IpForwardTable {
num_entries: u32,
entries: [IpForwardRow; 1],
}
impl IpForwardTable {
fn entries(&self) -> &[IpForwardRow] {
unsafe {
slice::from_raw_parts(
&self.entries[0],
self.num_entries as usize,
)
}
}
}
#[repr(C)]
#[derive(Debug)]
pub struct IpForwardRow {
dest: ipv4::Addr,
mask: ipv4::Addr,
policy: u32,
next_hop: ipv4::Addr,
if_index: u32,
#[debug(skip)]
_other_fields: [u32; 9],
}
#[repr(C)]
#[derive(Debug)]
pub struct IpInterfaceInfo {
num_adapters: u32,
adapter: [IpAdapterIndexMap; 1],
}
impl IpInterfaceInfo {
fn adapters(&self) -> &[IpAdapterIndexMap] {
unsafe {
slice::from_raw_parts(
&self.adapter[0],
self.num_adapters as usize,
)
}
}
}
#[repr(C)]
#[derive(Debug)]
pub struct IpAdapterIndexMap {
index: u32,
name: IpAdapterName,
}
pub struct IpAdapterName([u16; 128]);
impl fmt::Display for IpAdapterName {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let s = String::from_utf16_lossy(&self.0[..]);
write!(f, "{}", s.trim_end_matches("\0"))
}
}
impl fmt::Debug for IpAdapterName {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
crate::bind! {
library "IPHLPAPI.dll";
fn GetIpForwardTable(
table: *mut IpForwardTable,
size: *mut u32,
order: bool
) -> u32;
fn GetInterfaceInfo(
info: *mut IpInterfaceInfo,
size: *mut u32
) -> u32;
}
主程序里就可以恢复成非常直接的逻辑:
rust
mod loadlibrary;
mod netinfo;
mod vls;
mod ipv4;
fn main() -> Result<(), Error> {
let interface_name = format!(
r#"\Device\NPF_{}"#,
netinfo::default_nic_guid()?
);
let lib = rawsock::open_best_library()?;
lib.open_interface(&interface_name)?;
println!("Interface opened!");
Ok(())
}
这个版本不再依赖 WMI,不再依赖 serde、maplit 和 wmi。它直接从 Win32 IP Helper API 读取路由表和接口表,再打开对应 Npcap 接口。
十五、这一篇真正解决了什么
这一篇看起来只是在替换上一节的 WMI 查询方式,但实际内容很扎实。它解决了 Rust 绑定 Win32 C API 时三个非常典型的问题。
第一个问题是变长结构体。C API 经常用"结构体头部 + 尾部数组"的方式返回不定长数据。Rust 不能简单把这种结构放到栈上,也不能只用 MaybeUninit 解决。真正合适的方式是先查询所需大小,分配原始字节缓冲区,再把这段内存解释成 C 布局结构体。
第二个问题是生命周期。把 Vec<u8> 的指针转成 &T 很容易,但如果这个引用和 Vec 的生命周期没有绑定,就可能产生悬垂引用。VLS<T> 把拥有数据的 Vec<u8> 和对数据的结构体视图封装在一起,并通过 Deref 让引用生命周期跟随 VLS,至少让借用检查器重新参与进来。
第三个问题是 UTF-16。Windows API 很多地方使用 WCHAR,也就是 16 位宽字符。Rust 字符串是 UTF-8,不能直接把 [u16; 128] 当成普通字符串。需要用 String::from_utf16_lossy 转换,再去掉尾部空字符。前面几篇尽量使用 A 后缀 API 避开了 Unicode 细节,这一篇终于必须正面处理它。
十六、这一篇还有哪些风险
虽然 VLS<T> 已经让代码比手写 transmute(v.as_ptr()) 更安全,但它仍然不是完全安全的抽象。它假设 Win32 函数会按约定写入合法数据,也假设结构体定义与 C 结构体布局完全一致。只要某个字段类型、字段顺序、数组占位大小写错,解析就可能错位。
IpForwardRow 里的 _other_fields: [u32; 9] 就是一个很典型的例子。它不是业务字段,而是为了让 Rust 结构体大小和 C 结构体一致。如果少占了字段,第一行数据也许看起来正常,但第二行、第三行就会全部错位。系统编程里最危险的地方就在这里:代码可能能跑,也可能打印出貌似合理的数据,但其实内存解释已经错了。
entries() 和 adapters() 也仍然是 unsafe。它们使用 slice::from_raw_parts 根据结构体里的数量字段构造切片。如果 num_entries 或 num_adapters 不可信,或者 buffer 实际大小不足,就会越界读取。这里之所以接受,是因为数据来自 Windows API,而且 VLS::new 按 API 返回的大小分配了缓冲区。但这仍然属于必须小心维护的不变量。
错误处理也还可以继续改进。当前找不到默认路由或找不到接口时使用 expect,实验代码可以接受,但完整工具最好返回结构化错误,让上层决定如何提示用户。
十七、为什么这一步对后续很重要
后续如果要自己构造 Ethernet 帧、IPv4 包和 ICMP 报文,必须知道从哪个接口发送。上一节用 WMI 找到了默认接口,这一篇用更底层、更直接的 Win32 API 重新实现了同样能力。这样 ersatz 就不再依赖 WMI 这种系统管理接口,而是通过 IP Helper API 获取网络栈信息。
从架构上看,这一步把项目推进到更接近"自己实现协议栈"的方向。现在已经能打开默认 Npcap 接口,下一步就可以开始真正发送和接收原始帧。前几篇用 Windows ICMP API 发 ping,更多是在调用系统能力;从这里开始,程序逐渐拥有自己理解和处理底层网络结构的基础设施。
同时,这一篇也为后续 C API 绑定积累了通用工具。loadlibrary 宏可以绑定任意 DLL 函数,VLS<T> 可以处理类似"先查大小、再填 buffer"的变长结构体 API,ipv4::Addr 可以在 C 布局中直接作为 IPv4 地址字段使用,IpAdapterName 展示了如何处理固定长度 UTF-16 字符数组。这些不是只服务于当前两个函数的小技巧,而是后面继续写系统级 Rust 代码时会反复用到的模式。
十八、总结
这一篇把上一节基于 WMI 的默认网卡查询方案替换成了纯 Win32 API 实现。流程仍然是先找默认路由,再找对应网卡 GUID,但实现方式变了:GetIpForwardTable 读取 IPv4 路由表,从 0.0.0.0 默认路由拿到接口 index;GetInterfaceInfo 读取接口信息,用 index 找到对应适配器 name;适配器 name 是 UTF-16 字符串,其中包含 {GUID};最后把 GUID 拼成 Npcap 接口名并打开。
真正的难点不在查询逻辑,而在 Rust 如何绑定这些 C API。MIB_IPFORWARDTABLE 和 IP_INTERFACE_INFO 都是变长结构体,不能直接按普通 Rust 结构体分配。MaybeUninit 只能表示未初始化内存,不能表示"运行时才知道大小"的结构体。最终方案是用 Vec<u8> 承载原始内存,再用 VLS<T> 把这段内存和对应 C 结构体视图绑定起来。Deref 让 VLS<T> 可以像 T 一样访问字段,同时借用检查器会保证结构体引用不能比底层 buffer 活得更久。
为了读取变长数组,还需要在 IpForwardTable 和 IpInterfaceInfo 上分别实现 entries() 和 adapters(),用 slice::from_raw_parts 根据数量字段构造真实切片。为了保证每一行数据不错位,IpForwardRow 必须按照 C 结构体大小完整占位,即使很多字段当前并不关心。为了处理接口名,WCHAR[128] 被建模为 [u16; 128],再用 String::from_utf16_lossy 转成 Rust 字符串,并去掉末尾空字符。
这一篇没有发送任何数据包,但它完成了一个非常关键的底层准备:不再依赖 WMI,通过 Win32 API 直接找到默认网卡 GUID,并打开对应 Npcap 接口。更重要的是,它展示了 Rust 写系统级代码时经常遇到的核心问题:C 的内存布局、变长结构体、原始字节缓冲区、生命周期绑定、UTF-16 转换,以及如何把 unsafe 尽量封装在更小、更有类型约束的边界里。