绕过 WMI:用 Rust 绑定 Win32 变长结构体和 UTF-16 字符串

本文是对 Binding C APIs with variable-length structs and UTF-16 的整理与翻译。

内容结构概览

  1. 为什么抛弃 WMI:上一节用 WMI 找默认网卡,但 WMI 更偏系统管理,不适合作为底层网络程序的核心依赖。
  2. 回到 Win32 API :用 GetIpForwardTable 查询 IPv4 路由表,用 GetInterfaceInfo 查询接口信息。
  3. 先用 C 程序验证思路:按 Win32 API 的典型模式,第一次传空指针获取所需缓冲区大小,第二次分配内存再真正查询。
  4. 从路由表找默认接口 :在 MIB_IPFORWARDTABLE 中寻找 0.0.0.0 默认路由,拿到 dwForwardIfIndex
  5. 从接口表找 GUID :在 IP_INTERFACE_INFO / IP_ADAPTER_INDEX_MAP 中,根据接口 index 找到适配器 name,其中包含网卡 GUID。
  6. 改造 loadlibrary :把第 6 篇里写死 IPHLPAPI.dll 的宏改成可指定 DLL 名称的通用宏。
  7. 绑定 GetIpForwardTable :开始在 Rust 中定义 MIB_IPFORWARDTABLEMIB_IPFORWARDROW
  8. 变长 C 结构体的问题 :C 里 table[ANY_SIZE]Adapter[1] 这种结构,在 Rust 里不能简单放到栈上。
  9. 为什么 MaybeUninit 不够:问题不是"未初始化",而是"结构体实际大小要运行时才能知道"。
  10. Vec<u8> 承载原始内存:先用空指针查询大小,再分配字节缓冲区,最后把这段内存解释成 C 结构体。
  11. 引入 VLS<T> :用泛型容器把 Vec<u8> 和它对应的 C 结构体视图绑在一起,避免悬垂引用。
  12. 实现 Deref :让 VLS<IpForwardTable> 可以像 IpForwardTable 一样读取字段。
  13. 实现 entries()adapters() :从固定长度为 1 的数组起点,通过 slice::from_raw_parts 得到真实长度的切片。
  14. 补齐结构体布局IpForwardRow 不能只写关心的字段,必须用 _other_fields 占位,保证每一行大小正确。
  15. 处理 IPv4 地址显示 :复用 ipv4::Addr,让 destmasknext_hop 从整数变成 0.0.0.0 这种可读形式。
  16. 处理 UTF-16IP_ADAPTER_INDEX_MAP.NameWCHAR[128],在 Rust 里用 [u16; 128] 表示,并用 String::from_utf16_lossy 转成字符串。
  17. 最终得到默认网卡 GUID :用默认路由找到接口 index,再用接口表找到对应 name,从其中截取 {GUID},拼成 Npcap 接口名。

上一篇已经解决了一个关键问题:如果要自己发送原始网络数据包,就必须先找到默认网络接口。系统里可能有有线网卡、无线网卡、VPN、虚拟机网卡、环回接口和各种 WAN 适配器,不能靠猜,也不能靠接口列表顺序。上一节用 WMI 查询 Win32_IP4RouteTableWin32_NetworkAdapter,先从默认路由 0.0.0.0 找到 InterfaceIndex,再通过网卡信息找到 GUID,最后拼出 Npcap 能识别的接口名。

这条路能跑,但并不理想。WMI 更像是系统管理员查询系统信息的工具,而不是底层网络程序应该依赖的核心接口。它有查询语言,有 COM,有反序列化,有额外 crate,整个路径显得有点重。既然我们已经知道 Windows 里有 IPv4 路由表,也知道网络接口有 index,那么更直接的做法应该是调用 Win32 API 本身,而不是通过 WMI 间接查询。

这一篇就把上一节的 WMI 方案替换掉,改用 IPHLPAPI.dll 里的两个函数:GetIpForwardTableGetInterfaceInfo。前者能拿到 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 查询结果足够结构化。缺点也很明显:依赖 wmiserdemaplit 等额外 crate,路径比较绕,而且 WMI 本质上更偏系统管理场景。我们要写的是一个接近网络协议栈底层的程序,继续依赖查询语言和 WMI 管理接口,会让实现显得不够直接。

既然已经知道需要的信息来自 IPv4 路由表和网络接口表,就应该找有没有对应的 Win32 API。搜索 "Win32 API IPv4 routing table" 能找到 GetIpForwardTable。这个函数位于 IPHLPAPI.dll,而这个 DLL 前面已经很熟悉了:前几篇用 Windows ICMP API 实现 ping 时,IcmpCreateFileIcmpSendEchoIcmpCloseHandle 也都来自这里。

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,里面有 IndexName。在 Windows Vista 及之后,Name 可能是一个包含网络接口 GUID 的 Unicode 字符串,例如:

text 复制代码
\DEVICE\TCPIP_{0E89380B-814A-48FC-86C4-5C51B8040CB2}

这就和上一节 WMI 查到的 GUID 对上了。也就是说,完全可以不通过 WMI,而是直接用 GetIpForwardTableGetInterfaceInfo 获得同样结果。


二、先用一个 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 地址在这里就是一个 DWORD0.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;

它包含两个核心字段:IndexNameIndex 应该对应前面路由表里拿到的接口 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 模块,用于包装 LoadLibraryAGetProcAddress。第 6 篇还写过一个宏,用来绑定 IPHLPAPI.dll 里的函数,并用 once_cell::sync::Lazy 缓存函数指针。不过那个宏当时写死了 "IPHLPAPI.dll",只能服务于 sup 项目里的 ICMP 函数。

现在 ersatz 项目也需要绑定 IPHLPAPI.dll,可以把 loadlibrary.rssup 复制过来,再把宏改成更通用的形式,让调用方在宏里指定 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() 能得到非空指针,说明宏、LoadLibraryGetProcAddress 和函数指针缓存这条路径没有问题。等这条基础路径跑通,再绑定真正需要的 GetIpForwardTableGetInterfaceInfo


五、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 代表缓冲区不足。它还通过 transmuteVec<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 地址显示成正常形式

如果 destmasknext_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 派生了 PartialEqEq,所以可以直接比较。找到默认路由后,就拿到了 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 里的 NameWCHAR[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 里的 u16MAX_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 Stringlossy 的意思是如果遇到非法 UTF-16 序列,会用替代字符处理,而不是返回错误。在这个场景里,数据来自 Windows API,通常可以假设它是有效的接口名。

为什么要 trim_end_matches("\0")?因为 Name 是固定长度 128 的数组,而真实字符串可能远远短于 128。C 风格宽字符串会以空宽字符结尾,后面剩余位置也可能是 0。转成 Rust 字符串后,尾部会有很多 \0,必须去掉。

为了让 IpAdapterIndexMapderive(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,不再依赖 serdemaplitwmi。它直接从 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_entriesnum_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_IPFORWARDTABLEIP_INTERFACE_INFO 都是变长结构体,不能直接按普通 Rust 结构体分配。MaybeUninit 只能表示未初始化内存,不能表示"运行时才知道大小"的结构体。最终方案是用 Vec<u8> 承载原始内存,再用 VLS<T> 把这段内存和对应 C 结构体视图绑定起来。DerefVLS<T> 可以像 T 一样访问字段,同时借用检查器会保证结构体引用不能比底层 buffer 活得更久。

为了读取变长数组,还需要在 IpForwardTableIpInterfaceInfo 上分别实现 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 尽量封装在更小、更有类型约束的边界里。

相关推荐
飞天狗1111 小时前
零基础JavaWeb入门——第五课第二小节:九大内置对象 · 第2个:response(响应对象)
java·开发语言
DJ斯特拉1 小时前
axios快速使用
开发语言·前端·javascript
站大爷IP2 小时前
global和nonlocal到底有什么区别?
后端
二月龙2 小时前
从零开发 Shiny 交互式数据看板:本地运行到网页上线完整路径
后端
xingpanvip2 小时前
星盘接口开发文档:本命盘接口指南
android·开发语言·css·php·lua
小强19882 小时前
词云 + 情感分析:爬取评论数据做舆情可视化实战
后端
小强19882 小时前
高颜值动态可视化:gganimate 制作时序动图与数据短视频
后端
鱼人2 小时前
Shiny 模块化开发:大型数据分析平台拆分与代码复用实战
后端
长大19882 小时前
R 语言空间地图实战:从城市热力图到地理分布图,一篇吃透
后端
二月龙2 小时前
Shiny 对接 Excel / 数据库:从文件上传到自动分析
后端