YY菌 发表于 2024-2-6 17:28:39

【Socket】 关于WinSocket是否为文件描述符测试

本帖最后由 YY菌 于 2024-6-12 09:14 编辑

  关于Socket在Windows下是否文件描述符的说法由来已久,有些人认为它不是(理由是不能用CreateFile创建、ReadFile接收消息、WriteFile发送数据、CloseHandle关闭句柄),有些人认为它是(理由是可以绑定到IOCP,可以使用WaitFor系列API等待),那到底是不是呢?我们需要经过比较严格的测试之后再得出结论。

  首先测试socket函数的实现逻辑,经过反汇编单步调试发现,经过了一大堆“无关紧要”操作之后,最终走到NtCreateFile创建了一个名为"\\Device\\Afd\\Endpoint"的文件对象,最后返回给我们的SOCKET句柄也是这个NtCreateFile创建的文件对象句柄。接下来单步测试closesocket函数,发现这个SOCKET句柄最终走到NtClose上关闭的。既然WinSockRuntime内部都是使用NtCreateFile创建和NtClose关闭的,那为什么不能自己CreateFile创建和CloseHandle关闭呢?这个问题的关键就在于我们看似“无关紧要”的那些操作上,因为WinSockRuntime内部会记录一些额外的附加数据来和SOCKET句柄关联(大概是类似哈希表的结构,SOCKET句柄为Key,附加数据为Value)。如果我们使用CreateFile来创建"\\Device\\Afd\\Endpoint"的话,WinSockRuntime是不能识别的,而用CloseHandle关闭SOCKET句柄虽然调用结果是成功的,但WinSockRuntime的附加数据并没有得到相应的释放从而发生内存泄漏,最后在WSACleanup时引发0xC0000008异常。

  至于send和recv的内部实现是通过NtDeviceIoControlFile来调用内核中Afd驱动的功能来实现的,由此可见这两个函数是不走ReadFile和WriteFile(或NtReadFile和NtWriteFile)的,当我们自己调用ReadFile和WriteFile来收发消息得到的结果是调用失败,于是很多人因此得出结论SOCKET对象在Windows下不是文件描述符,不能使用ReadFile和WriteFile来像Linux那样收发消息。然而,事实上真的是这样的吗?接下来我们再试试更高级版本的WSASocket函数来创建SOCKET对象(比socket函数多出来的参数全部传0或NULL),这时候再来使用ReadFile和WriteFile来收发消息试试,居然成功了,所以说Windows的SOCKET对象其实也是和Linux一样的文件描述符。可是socket函数创建的句柄为什么不行呢?再反汇编单步调试一下发现,原来socket函数也是调用的WSASocket函数,但它的最后一个参数dwFlags传的是1,1对应的常量则是WSA_FLAG_OVERLAPPED(重叠模式),而我们习惯的ReadFile和WriteFile用法都是非重叠阻塞调用方式,所以这就是我们使用ReadFile和WriteFile来收发SOCKET消息失败的重要原因。

  接下来,我们分别测试非重叠SOCKET、重叠SOCKET、非重叠IO操作、重叠IO操作的组合操作结果:

非重叠SOCKET重叠SOCKET
非重叠IO操作成功:通过阻塞等待IO完成失败:GetLastError结果为87(无效参数)
重叠IO操作成功:忽略重叠IO参数并通过阻塞等待IO完成成功:函数立即返回,GetLastError为997,可通过GetOverlappedResult或其它异步方式来等待结果。

根据以上测试结果,我分别使用ReadFile和WriteFile来封装了模拟Linux的read和write的版本:

#ifdef _WIN32
typedef ptrdiff_t ssize_t;

// 转换 Win32 LastError 为 CRT errno
errno_t _errno_cvt_win2crt(DWORD winerr);      // 这个是Windows错误转CRT错误码(不提供实现,请自行封装)

// 把 Win32 LastError 转换并设置到 CRT errno
static int _set_winerrno(int err)
{
      err = _errno_cvt_win2crt(err);
#ifdef _MSC_VER
      __if_exists(_set_errno) {
                // 如果存在 _set_errno 标识符,说明是新版 MSVCRT,最好是调用 _set_errno 函数来设置。
                _set_errno(err);
      }
      __if_not_exists(_set_errno) {
                // 如果不存在 _set_errno 标识符,说明是旧版 MSVCRT,直接对 errno 赋值就行了。
                errno = (err);
      }
#else
      errno = (err);
#endif
      return -1;
}

// 从文件描述符读取数据
ssize_t read(int fd, void *buf, size_t count)
{
      DWORD dwret;
      OVERLAPPED ol = {};
      // 使用重叠模式读取(注意这一步非常关键,否则无法成功读取 socket 数据)
      BOOL issuc = ReadFile(HANDLE(fd), buf, DWORD(count), &dwret, &ol);
      // 如果是非重叠模式对象会发生阻塞直到读取成功或失败,直接返回长度即可(注意:socket 对象只有用 WSASocket 才能创建非重叠 socket 对象)
      if (issuc) return ssize_t(dwret);
      // 获取错误码,如果不是处理重叠读取状态中,就说明是真的发生了错误。
      dwret = GetLastError();
      if (ERROR_IO_PENDING != dwret) return _set_winerrno(dwret);
      // 等待重叠读取完成并获取结果(模拟成非重叠的阻塞读取模式)
      issuc = GetOverlappedResult(HANDLE(fd), &ol, &dwret, TRUE);
      // 如果获取成功就返回值读取长度
      if (issuc) return ssize_t(dwret);
      // 否则需将 Win32 LastError 转换成 CRT errno
      dwret = GetLastError();
      return _set_winerrno(dwret);
}

// 写入数据到文件描述符
ssize_t write(int fd, const void *buf, size_t count)
{
      DWORD dwret;
      OVERLAPPED ol = {};
      // 使用重叠模式写入(注意这一步非常关键,否则无法成功写入 socket 数据)
      BOOL issuc = WriteFile(HANDLE(fd), buf, DWORD(count), &dwret, &ol);
      // 如果是非重叠模式对象会发生阻塞直到写入成功或失败,直接返回长度即可(注意:socket 对象只有用 WSASocket 才能创建非重叠 socket 对象)
      if (issuc) return ssize_t(dwret);
      // 获取错误码,如果不是处理重叠写入状态中,就说明是真的发生了错误。
      dwret = GetLastError();
      if (ERROR_IO_PENDING != dwret) return _set_winerrno(dwret);
      // 等待重叠写入完成并获取结果(模拟成非重叠的阻塞写入模式)
      issuc = GetOverlappedResult(HANDLE(fd), &ol, &dwret, TRUE);
      // 如果获取成功就返回值写入长度
      if (issuc) return ssize_t(dwret);
      // 否则需将 Win32 LastError 转换成 CRT errno
      dwret = GetLastError();
      return _set_winerrno(dwret);
}
#endif

  测试使用read和write来收发socket消息的代码:

#include <stdio.h>
#include <stdlib.h>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <WinSock2.h>
#pragma comment(lib, "ws2_32")
#else
#include <sys/types.h>
#include <sys/socket.h>
// 让 Linux 和 Windows 通用的别名
typedef struct sockaddr SOCKADDR, *PSOCKADDR;
typedef struct sockaddr_in SOCKADDR_IN, *PSOCKADDR_IN;
#define closesocket close
#endif

//#define TEST 0
int main()
{
      int ret;
#ifdef _WIN32
      // Win Socket 初始化
      WSADATA wsad;
      ret = WSAStartup(MAKEWORD(2, 2), &wsad);
#endif
      // 创建 socket 对象(这里用本机UDP测试)
#if !defined(TEST) || !defined(_WIN32)
      auto sfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
      //auto sfd2 = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
#elif TEST == 1
      auto sfd = WSASocket(AF_INET, SOCK_DGRAM, IPPROTO_UDP, nullptr, 0, WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT);
#else
      auto sfd = WSASocket(AF_INET, SOCK_DGRAM, IPPROTO_UDP, nullptr, 0, WSA_FLAG_NO_HANDLE_INHERIT);
#endif

      // 设定 IPv4 127.0.0.1:21930
      SOCKADDR_IN si = { AF_INET, 0xAA55, };
      si.sin_addr.s_addr = 0x0100007F;
      // 让 UDP 对象同时绑定和连接同一个IP端口(实现自发自收)
      ret = bind(sfd, PSOCKADDR(&si), sizeof(si));
      ret = connect(sfd, PSOCKADDR(&si), sizeof(si));

      // 发送消息(write 等于 send 的最后一个参数传0)
      static const char send_data[] = "HelloWorld";
      //ret = send(sfd, send_data, sizeof(send_data), 0);
      ret = write(sfd, send_data, sizeof(send_data));

      // 接收消息(read 等于 recv 的最后一个参数传0)
      char recv_data;
      //ret = recv(sfd, recv_data, sizeof(recv_data), 0);
      ret = read(sfd, recv_data, sizeof(recv_data));

      // 关闭 socket 句柄
      ret = closesocket(sfd);
#ifdef _WIN32
      // Win Socket 清理
      ret = WSACleanup();
#endif
      // 输出结果
      printf("%s\n", recv_data);
      return EXIT_SUCCESS;
}

美俪女神 发表于 2024-2-6 23:27:02

在WINDOWS上,只要有OPEN/CREATE---USE---CLOSE流程的,肯定有句柄(不管名字叫啥)。但至于具体的句柄类型,就要用ARK查看了。

但有些名字里有“句柄”的反而不一定是,比如“窗口句柄”。

YY菌 发表于 2024-2-7 10:01:49

美俪女神 发表于 2024-2-6 23:27
在WINDOWS上,只要有OPEN/CREATE---USE---CLOSE流程的,肯定有句柄(不管名字叫啥)。但至于具体的句柄类型 ...

窗口句柄也是句柄,Windows的句柄一共分为三大类:内核对象句柄、用户对象句柄、GDI对象句柄。像文件句柄、Socket句柄、事件句柄、IOCP句柄这些都是内核对象句柄,HWND、HMONITOR、HICON、HCURSOR、HMENU、HACCEL都是用户对象句柄,HDC、HBITMAP、HMETAFILE、HPEN、HBRUSH、HPALETTE、HFONT、HRGN都是GDI对象句柄。这三大类句柄都是分别显示在任务管理器的三个不同列上的,其中用户对象句柄和GDI对象句柄存在最多一万个的上限,而内核对象句柄则没有。不过Windows确实还有一些不是句柄的句柄,比如:FindFirstFile返回的句柄(好像是指向保存当前查找状态数据的指针)、WSAAsyncGet返回的句柄(实际上只是个纯编号)、HINSTANCE(实际上的PE模块的内存基址)。
至于研究Windows的Socket对象是不是文件描述符这类句柄,最开始是因为看到A5大婶说到Windows的Socket对象和Linux的不一样,Linux的可以用read和write收发消息(因此它是文件描述符),Windows的不可以用ReadFile和WriteFile不可以(因此它不是),但我清晰记得MSDN曾经说过ReadFile和WriteFile是可以用于socket的对象的重叠IO操作的,但现在再去看MSDN结果那句话已经被巨硬删掉了。所有便做了个测试来验证,最后验证结果确实是可以用ReadFile和WriteFile来收发网络消息(只不过socket默认是重叠IO模式)。

lichao 发表于 2024-2-11 00:01:05

本帖最后由 lichao 于 2024-2-11 00:04 编辑

一切皆文件,包括目录,管道,fifo,设备。这点Linux也一样
socket读写有send/recv/sendto/recvfrom/read/write/sengmsg/recvmsg
页: [1]
查看完整版本: 【Socket】 关于WinSocket是否为文件描述符测试