Winsock 协议与名称空间 Provider 目录
文章所述功能来自KswordARK,项目开源地址 https://github.com/KSwordDEV/KSword

要取得当前进程可见的 Winsock 目录,分为四步
本篇要得到当前进程可见的 Winsock 协议目录和名称空间目录,并读出每个条目的身份、类型和活动状态。这个过程只读取目录,不安装、删除或调整提供程序。要完成这项读取,分为四步:
- 认识目录中的对象,并初始化 Winsock
- 测量缓冲区并读取 Protocol Catalog
- 测量缓冲区并读取 Name Space Catalog
- 解读条目、记录查询范围,并归还 Winsock 引用
Windows Sockets(Winsock)是 Windows 程序访问网络的公共接口。应用程序调用 socket、connect、send、recv 或名称查询函数时,调用不会直接面对网卡驱动。Winsock 根据目录中的提供程序信息选择实现,再由协议栈、网络设备和远端系统完成通信。目录是系统网络配置的一部分,读取目录能够说明当前进程看见哪些提供程序,不能单独证明某个程序已经使用某一项。
Winsock 有两类目录。协议目录(Protocol Catalog)回答"某种地址族、套接字类型和协议如何收发数据"。名称空间目录(Name Space Catalog)回答"某类名称如何解析为地址或服务"。两类条目都带 GUID 和显示名,却处在不同调用链。排查网络异常时先分清这两个角色,再检查条目的位数、目录 ID 与返回错误。
创建套接字:应用参数 -> Protocol Catalog -> 协议 Provider -> 网络栈
名称查询:查询参数 -> Name Space Catalog -> 名称空间 Provider -> 地址结果
只读检查:WSAStartup -> 测量所需字节数 -> 分配缓冲区 -> 枚举条目 -> WSAcCleanup
一、第一步:认识目录中的对象,并初始化 Winsock
协议目录条目用 WSAPROTOCOL_INFOW 描述。条目中的 iAddressFamily 表示地址族,例如 AF_INET 和 AF_INET6。iSocketType 表示 SOCK_STREAM、SOCK_DGRAM 等套接字语义。iProtocol 是 IPPROTO_TCP、IPPROTO_UDP 等协议号。GUID(Globally Unique Identifier,全局唯一标识符)是 Windows 用 128 位值标识对象的格式,ProviderId 用它标识提供程序。dwCatalogEntryId 是当前目录中的条目身份。链式提供程序还会由多个目录项共同组成。
名称空间目录条目用 WSANAMESPACE_INFOW 描述。dwNameSpace 表示名称空间类别,例如 NS_DNS 代表 DNS,NS_NLA 与网络位置感知有关。NSProviderId 表示提供程序身份,fActive 表示条目当前活动状态,lpszIdentifier 是显示文本。名称空间条目参与主机名、服务名或服务发现查询,它不承担 TCP 字节发送。
调用 socket:地址族、套接字类型和协议 -> Protocol Catalog -> 协议 Provider -> 连接、收发和套接字选项
调用 getaddrinfo:名称查询 -> Name Space Catalog -> DNS、NLA 或其它 Provider -> 候选地址
同名条目不能仅凭显示文本合并。目录 ID 与 GUID 才能帮助识别条目。地址族、进程位数和目录种类决定其语义。一个网络程序可以只使用协议提供程序,也可以先经过名称解析后再创建套接字。
初始化是当前进程的前置条件
在调用大多数 Winsock API 前,当前进程要先调用 WSAStartup。函数的返回值为 0 表示成功,非零值直接就是 Winsock 错误码。成功调用一次,就要在同一进程中调用一次 WSACleanup 归还初始化计数。WSADATA 由调用方提供存储空间,函数在其中写入协商后的版本和实现信息。
cpp
// 作用:初始化当前进程的 Winsock 使用计数并协商 API 版本。
// 返回:0 成功。非零值是 Winsock 错误码,不依赖 WSAGetLastError。
int WSAStartup(
WORD wVersionRequired, // 请求版本,例如 MAKEWORD(2, 2)
LPWSADATA lpWSAData // 输出:版本、描述和实现信息。调用方拥有此结构
);
// 作用:与一次成功的 WSAStartup 配对,减少当前进程的初始化计数。
// 返回:0 成功。SOCKET_ERROR 失败,失败时可调用 WSAGetLastError。
int WSACleanup(void);
WSADATA data{};
int result = WSAStartup(MAKEWORD(2, 2), &data);
if (result == 0) {
// 在这里读取目录。完成后必须调用 WSACleanup()。
}
初始化只影响当前进程,不会修复、安装或重排系统目录。WSASYSNOTREADY 通常表示网络子系统未准备好。版本协商失败说明请求版本不被实现支持。访问限制也应和"目录为空"分开记录。
完成第一步后,当前进程已经取得可用的 Winsock 引用,也能区分协议目录和名称空间目录的用途。接下来读取 Protocol Catalog,才能把"支持哪些网络通信组合"变成可核对的条目。
二、第二步:测量缓冲区并读取 Protocol Catalog
WSCEnumProtocols 读取协议目录。该 SPI 函数的结构类型固定为 WSAPROTOCOL_INFOW,因此函数名称本身没有 W 后缀。首次调用将 lpBuffer 设为 nullptr。典型结果是 SOCKET_ERROR,同时 lpErrno 写入 WSAENOBUFS,lpdwBufferLength 写入所需容量。容量的单位是字节。分配该容量后重复调用,函数返回的非负数才是有效 WSAPROTOCOL_INFOW 条目数。
cpp
// 作用:枚举当前进程可见的协议目录条目。
// 返回:非负数为条目数。SOCKET_ERROR 失败,lpErrno 接收扩展错误。
INT WSAAPI WSCEnumProtocols(
LPINT lpiProtocols, // 可选协议号过滤数组。nullptr 表示全部
LPWSAPROTOCOL_INFOW lpBuffer, // 输出:连续条目缓冲区。首次测量可为 nullptr
LPDWORD lpdwBufferLength, // 输入容量、输出所需或实际容量,单位为字节
LPINT lpErrno // 输出:WSAENOBUFS 等扩展错误码
);
DWORD bytes = 0;
int error = 0;
int count = WSCEnumProtocols(nullptr, nullptr, &bytes, &error);
if (count == SOCKET_ERROR && error == WSAENOBUFS && bytes != 0) {
std::vector<BYTE> raw(bytes);
DWORD capacity = bytes;
count = WSCEnumProtocols(nullptr,
reinterpret_cast<LPWSAPROTOCOL_INFOW>(raw.data()),
&capacity, &error);
// count 才是元素数量,capacity 仍然是字节数。
}
目录可能在两次调用间被安装程序或网络维护操作改写。第二次仍返回 WSAENOBUFS 时,应采用有限次数的重新分配和重试。容量没有增长、错误码为非 WSAENOBUFS 值、或超出合理上限时停止。把 bytes / sizeof(WSAPROTOCOL_INFOW) 当成条目数是错误的,因为 bytes 是容量,返回结构中也可能带有不同版本字段。
错误示例:
cpp
DWORD bytes = 4096;
std::vector<BYTE> raw(bytes);
int assumed = static_cast<int>(bytes / sizeof(WSAPROTOCOL_INFOW));
// 错误:assumed 只是可能容纳的结构数量,并非 API 实际写入的条目数。
枚举结果应保存条目数、目录 ID、Provider GUID、地址族、套接字类型、协议号、链长度和显示名。iProtocol 是数字协议标识,不能当作 DLL 文件路径。Provider GUID 是身份标识,DLL 定位属于更深一层安装信息。
完成第二步后,已经得到当前进程可用于建连和收发数据的协议条目。名称解析仍由另一套目录负责,接下来读取 Name Space Catalog,才能知道名称会交给哪些解析实现处理。
三、第三步:测量缓冲区并读取 Name Space Catalog
WSCEnumNameSpaceProvidersW 提供当前进程可见的名称空间条目。它的 lpdwBufferLength 同样以字节计量,成功返回条目数量,失败返回 SOCKET_ERROR,错误通过 WSAGetLastError 取得。首次测量后再调用时也可能遇到目录变化。
cpp
// 作用:枚举名称空间 Provider 目录。
// 返回:非负数为条目数。SOCKET_ERROR 失败,使用 WSAGetLastError 获取错误。
INT WSAAPI WSCEnumNameSpaceProvidersW(
LPDWORD lpdwBufferLength, // 输入容量、输出所需容量,单位为字节
LPWSANAMESPACE_INFOW lpnspBuffer // 输出:连续条目。测量阶段可为 nullptr
);
DWORD bytes = 0;
int count = WSCEnumNameSpaceProvidersW(&bytes, nullptr);
if (count == SOCKET_ERROR && bytes != 0) {
std::vector<BYTE> raw(bytes);
DWORD capacity = bytes;
count = WSCEnumNameSpaceProvidersW(&capacity,
reinterpret_cast<LPWSANAMESPACE_INFOW>(raw.data()));
}
lpszIdentifier 可能为空,未知 dwNameSpace 数值也可能在新系统或第三方提供程序上出现。读取工具应保留原始数字和 GUID,而非因显示文本为空自动删除条目。名称空间不存在、条目不活动、缓冲区不足、网络子系统未准备好和访问遭拒是不同的诊断结论。
完成第三步后,两个目录都已读入内存。接下来要结合进程位数、条目字段和错误状态解释这些记录,再在读取结束时归还 Winsock 初始化引用。
四、第四步:解读条目、记录查询范围,并归还 Winsock 引用
32 位与 64 位目录视图
Winsock 目录按进程位数区分。64 位进程查询到的目录可与 32 位进程不同。安装器、兼容层和提供程序架构都会影响可见条目。记录结果时至少包含操作系统架构、查询进程位数、Protocol Catalog 或 Name Space Catalog 类别、目录 ID、Provider GUID 和查询时的错误码。
目录安装、删除和顺序调整影响系统范围内的网络程序。遇到异常时先进行只读枚举并保留快照,再使用受支持的网络维护接口或供应商安装程序处理。直接删除注册表字符串可能留下引用、顺序和跨位数目录不一致的问题。
从进程初始化到目录记录的完整流程
这一流程的输入是当前进程请求的 Winsock 版本,输出是该进程可见的协议目录和名称空间目录快照。Winsock DLL 维护每个进程的初始化计数。WSAStartup 成功后创建一次可用引用,WSACleanup 归还一次引用。WSADATA 是调用方在栈或其他可用存储中分配的结构,Winsock 在初始化成功时写入协商版本。目录缓冲区由调用方分配,API 只在调用期间借用它,缓冲区销毁后其中的结构指针随之失效。
text
请求 Winsock 2.2 -> WSAStartup 写入 WSADATA
->
空缓冲区测量 Protocol Catalog 的字节数
->
分配字节缓冲区 -> 重复查询 -> 用返回条目数遍历
->
空缓冲区测量 Name Space Catalog -> 同样读取
->
记录目录 ID、GUID、类型、进程位数和错误状态
->
WSACleanup 归还初始化引用
WSAGetLastError 只适用于返回 SOCKET_ERROR 且文档要求通过 Winsock 错误槽取得原因的 API。WSCEnumProtocols 把扩展错误写入其 lpErrno 参数,读取该输出参数。名称空间枚举没有该参数,才在失败后立即调用 WSAGetLastError。错误码要在日志格式化或 GUID 转换前保存,因为后续 API 可能覆盖线程错误状态。
cpp
// 作用:返回当前线程最近一次失败的 Winsock API 错误码。
// 返回:WSA* 错误整数。没有参数、没有资源所有权和长度单位。
int WSAGetLastError(void);
下面的正确示范完整枚举协议目录。它先处理"首次调用预期容量不足"的状态,再处理第二次调用期间目录变动导致的重复不足。count 是可访问结构数,capacity 是字节数,二者不能混用。
cpp
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <winsock2.h>
#include <ws2spi.h>
#include <vector>
int EnumerateProtocolCatalog() {
WSADATA startupData{};
int startup = WSAStartup(MAKEWORD(2, 2), &startupData);
if (startup != 0) return startup; // 返回值直接是错误码。尚未产生清理责任
DWORD requiredBytes = 0;
int catalogError = 0;
int count = WSCEnumProtocols(
nullptr, // 输入:nullptr 表示不按协议号过滤
nullptr, // 输出:测量阶段没有接收缓冲区
&requiredBytes, // 输出:需要的容量,单位为字节
&catalogError); // 输出:函数失败时写入 WSAENOBUFS 等错误
if (count != SOCKET_ERROR || catalogError != WSAENOBUFS || requiredBytes == 0) {
WSACleanup();
return catalogError;
}
for (int retry = 0; retry != 3; ++retry) {
std::vector<BYTE> bytes(requiredBytes); // vector 拥有缓冲区并在循环最后释放
DWORD capacity = requiredBytes;
count = WSCEnumProtocols(nullptr,
reinterpret_cast<LPWSAPROTOCOL_INFOW>(bytes.data()), // 输出:连续结构起始地址
&capacity, &catalogError);
if (count != SOCKET_ERROR) {
const auto* entries = reinterpret_cast<const WSAPROTOCOL_INFOW*>(bytes.data());
for (int index = 0; index < count; ++index) {
const WSAPROTOCOL_INFOW& item = entries[index];
// item 只在 bytes 存活期间可读。这里记录 item.dwCatalogEntryId 和 item.ProviderId。
}
WSACleanup();
return 0;
}
if (catalogError != WSAENOBUFS || capacity <= requiredBytes) break;
requiredBytes = capacity; // 目录在两次调用间增大,使用新字节容量重试
}
WSACleanup();
return catalogError;
}
名称空间目录的标准流程相同,只是错误传递方式不同。lpnspBuffer 接收 WSANAMESPACE_INFOW 数组,条目中的 lpszIdentifier 是 API 管理的指针,仅在调用方提供的缓冲区仍然存活时使用。保存结果时复制文本和 GUID。
cpp
DWORD bytes = 0;
int count = WSCEnumNameSpaceProvidersW(
&bytes, // 输出:测量阶段返回所需容量,单位为字节,不能为 nullptr
nullptr); // 输出:测量阶段不接收结构
if (count == SOCKET_ERROR && bytes != 0) {
const int measureError = WSAGetLastError(); // 立即读取失败原因
std::vector<BYTE> raw(bytes);
DWORD capacity = bytes;
count = WSCEnumNameSpaceProvidersW(
&capacity, // 输入可用字节数,输出实际/所需字节数
reinterpret_cast<LPWSANAMESPACE_INFOW>(raw.data()));
if (count == SOCKET_ERROR) {
const int readError = WSAGetLastError();
// readError == WSAENOBUFS 时可在有限次数内扩容并重试。
}
}
错误做法是在没有调用 WSAStartup 时直接读取目录,或在 WSAStartup 成功后遗漏 WSACleanup。前者可能得到 WSASYSNOTREADY 或其他初始化失败状态,后者使进程内引用计数不能按预期归还。另一个错误是保留 WSAPROTOCOL_INFOW* 或 lpszIdentifier 指针到 std::vector<BYTE> 离开作用域之后。那时底层缓冲区已释放,指针不再有效。
完整可运行程序在附件
