8、网络编程基础:Socket API 的跨平台封装 (Berkeley sockets vs Winsock)

网络编程这块,说实话,是跨平台开发里最容易踩坑的地方之一。你想想看,Windows 和 Linux 都提供了 Socket API,但细节上就是不一样。我在早期做项目时,就吃过这个亏——代码在 Linux 上跑得好好的,移植到 Windows 上一编译,报错一大堆。

说白了,Berkeley sockets 是事实上的标准,几乎所有 Unix-like 系统都支持它。而 Winsock 是 Windows 上的实现,虽然大体上兼容,但总有些「小脾气」。今天我们就来聊聊怎么封装它们,让一套代码能在两边都跑起来。

核心思路:用条件编译 + 抽象层,把平台差异隔离在底层。上层业务代码只调用我们封装的统一接口。

8.1 两个世界的差异

先看看 Berkeley sockets 和 Winsock 到底差在哪。我列个表,你一看就明白:

对比项 Berkeley sockets (Linux/Unix) Winsock (Windows)
头文件 #include <sys/socket.h>
#include <netinet/in.h>
#include <winsock2.h>
初始化 不需要 需要调用 WSAStartup()
清理 不需要 需要调用 WSACleanup()
Socket 类型 int (非负整数) SOCKET (实际上是 UINT_PTR)
关闭 socket close(fd) closesocket(s)
错误码获取 errno WSAGetLastError()
阻塞/非阻塞 fcntl() 设置 O_NONBLOCK ioctlsocket() 设置 FIONBIO

看到没?光是头文件和关闭方式就不一样。我记得第一次把 Linux 上的网络代码搬到 Windows 时,close() 函数死活找不到,查了半天才发现 Windows 用的是 closesocket()

8.2 封装策略:条件编译 + 抽象层

我的做法是:定义一个平台无关的接口,然后用条件编译分别实现。这样上层代码完全不用关心底层是哪个平台。

先定义一个抽象层头文件 net_common.h

#ifndef NET_COMMON_H
#define NET_COMMON_H

#ifdef _WIN32
    #include <winsock2.h>
    #include <windows.h>
    #pragma comment(lib, "ws2_32.lib")
#else
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <unistd.h>
    #include <fcntl.h>
    #include <errno.h>
#endif

// 统一 socket 类型
#ifdef _WIN32
    typedef SOCKET socket_t;
    #define INVALID_SOCKET_VAL INVALID_SOCKET
#else
    typedef int socket_t;
    #define INVALID_SOCKET_VAL (-1)
#endif

// 统一关闭函数
static inline int net_close(socket_t fd) {
#ifdef _WIN32
    return closesocket(fd);
#else
    return close(fd);
#endif
}

// 统一错误码获取
static inline int net_get_error(void) {
#ifdef _WIN32
    return WSAGetLastError();
#else
    return errno;
#endif
}

// 统一初始化/清理
static inline int net_init(void) {
#ifdef _WIN32
    WSADATA wsa;
    return WSAStartup(MAKEWORD(2, 2), &wsa);
#else
    return 0; // Linux 不需要初始化
#endif
}

static inline void net_cleanup(void) {
#ifdef _WIN32
    WSACleanup();
#endif
}

#endif // NET_COMMON_H

我的习惯:把平台差异封装成内联函数,而不是宏。这样调试时能看到函数调用栈,比宏友好得多。

8.3 实战:跨平台 TCP 客户端

有了上面的封装层,写一个跨平台的 TCP 客户端就简单了。来看看代码:

#include "net_common.h"
#include <stdio.h>
#include <string.h>

int main() {
    // 初始化网络库(Windows 需要)
    if (net_init() != 0) {
        printf("网络初始化失败\n");
        return -1;
    }

    // 创建 socket
    socket_t sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock == INVALID_SOCKET_VAL) {
        printf("创建 socket 失败, 错误码: %d\n", net_get_error());
        net_cleanup();
        return -1;
    }

    // 连接服务器
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8080);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        printf("连接失败, 错误码: %d\n", net_get_error());
        net_close(sock);
        net_cleanup();
        return -1;
    }

    printf("连接成功!\n");

    // 发送数据
    const char *msg = "Hello from cross-platform client!";
    send(sock, msg, strlen(msg), 0);

    // 接收数据
    char buffer[1024] = {0};
    int len = recv(sock, buffer, sizeof(buffer) - 1, 0);
    if (len > 0) {
        printf("收到: %s\n", buffer);
    }

    // 清理
    net_close(sock);
    net_cleanup();
    return 0;
}

你看,这套代码在 Linux 和 Windows 上都能编译通过。唯一要注意的是,Windows 上需要链接 ws2_32.lib,我在头文件里已经用 #pragma comment(lib, ...) 处理了。

8.4 避坑指南

我曾经踩过的坑:

  • Winsock 初始化顺序:必须在任何 socket 操作之前调用 WSAStartup()。我试过在创建 socket 之后才初始化,结果返回 WSANOTINITIALISED,折腾了半天。
  • socket 类型不一致:Linux 上用 int,Windows 上用 SOCKET。如果你直接拿 int 去比较 INVALID_SOCKET,在 Windows 上可能出问题。所以我统一用了 socket_t
  • 错误码处理:Windows 的 WSAGetLastError() 和 Linux 的 errno 值不一样。比如 EAGAIN 在 Linux 上是 11,在 Windows 上对应的是 WSAEWOULDBLOCK (10035)。
  • 关闭 socket:Windows 上不能用 close(),必须用 closesocket()。而且 closesocket() 可能返回 SOCKET_ERROR,不是 -1。

8.5 非阻塞模式封装

非阻塞模式也是常见需求。来看看怎么封装:

static inline int net_set_nonblock(socket_t fd, int nonblock) {
#ifdef _WIN32
    u_long mode = nonblock ? 1 : 0;
    return ioctlsocket(fd, FIONBIO, &mode);
#else
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    if (nonblock)
        flags |= O_NONBLOCK;
    else
        flags &= ~O_NONBLOCK;
    return fcntl(fd, F_SETFL, flags);
#endif
}

嗯,这里要注意:Windows 的 ioctlsocket() 用的是 u_long 指针,而 Linux 的 fcntl() 用的是位操作。封装之后,上层调用就统一了:

// 设置为非阻塞
net_set_nonblock(sock, 1);

// 设置为阻塞
net_set_nonblock(sock, 0);

8.6 知识体系总览

下面这张图,把 Socket API 跨平台封装的核心逻辑梳理了一遍:

Socket API 跨平台封装架构 统一接口层 (net_common.h) 抽象封装层 (内联函数 + 类型定义) socket_t | net_close() | net_get_error() | net_init() | net_set_nonblock() Berkeley Sockets (Linux/Unix) 头文件: sys/socket.h, netinet/in.h 关闭: close(fd) | 错误: errno 非阻塞: fcntl() + O_NONBLOCK Winsock (Windows) 头文件: winsock2.h 关闭: closesocket() | 错误: WSAGetLastError() 非阻塞: ioctlsocket() + FIONBIO 底层实现: #ifdef _WIN32 / #else / #endif

说白了,整个封装的核心就是:上层统一,下层隔离。业务代码只跟统一接口打交道,平台差异全部藏在条件编译后面。

8.7 小结

跨平台网络编程,其实没那么玄乎。你只要记住几个关键点:

  • #ifdef _WIN32 区分平台
  • 把 socket 类型、关闭函数、错误码都封装起来
  • Windows 别忘了 WSAStartup()WSACleanup()
  • 非阻塞模式用统一的 net_set_nonblock()

我个人习惯是,每个新项目都先搭好这个封装层。虽然第一次花点时间,但后面移植起来就轻松多了。你想想看,如果代码里到处是 #ifdef,那维护起来得多痛苦?

一点建议:如果你用 CMake,可以在 CMakeLists.txt 里自动检测平台,然后定义宏。这样代码里只需要 #ifdef PLATFORM_WINDOWS 就行,更清晰。


公众号:蓝海资料掘金营,微信deep3321