这篇博客讲述了如何使用Windows API编程作为服务端建立TCP连接。

一、WinSock头文件与链接库

Winsock是Windows下网络编程的规范,该规范是Windows下得到广泛应用的、开放的、支持多种协议的网络编程接口。使用它们需要包含以下两个头文件其一:

#include <WinSock2.h>
#include <Winsock.h>

Winsock有两个版本,在本文中我将使用WinSock2。

然后使用以下代码连接库:

#pragma comment(lib, "ws2_32.lib")

注意,这是MSVC的写法。如果使用MingGW之类的编译器,需要在编译时指定链接的库。

为了移植性,我们可以考虑使用一些宏对其进行处理,例如:

// 第一个宏判断的是电脑是否为32位或64位的Windows,第二个宏判断的是编译器是否为MSVC。
#if ((defined _WIN32) || (defined _WIN64))
#include <WinSock2.h>
#endif

#ifdef _MSC_VER
#pragma comment(lib, "ws2_32.lib")
#endif

二、打开网络库

要使用Socket,必须打开网络库。打开后,要关闭网络库:

可以使用WSAStartup函数打开网络库,使用WSACleanup关闭网络库,其函数定义为:

int WSAAPI WSAStartup(
    WORD      wVersionRequested,
    LPWSADATA lpWSAData
);

函数名中,W表示Windows,S表示Socket,A表示Asynchronous。函数的第一个参数指定的是使用的Winsock库版本,第二个参数为一个叫做WSADATA的结构的指针,其定义如下:

typedef struct WSAData {
    WORD                    wVersion;       // 使用Winsock库的版本
    WORD                    wHighVersion;   // 系统提供的Winsock库的最高版本
#ifdef _WIN64
    unsigned short          iMaxSockets;    // 返回可用的socket的数量,Winsock2中被弃用
    unsigned short          iMaxUdpDg;      // UDP数据报信息的大小,Winsock2中被弃用
    char FAR *              lpVendorInfo;   // 供应商的特定信息,Winsock2中被弃用
    char                    szDescription[WSADESCRIPTION_LEN+1];    // 当前库的描述信息
    char                    szSystemStatus[WSASYS_STATUS_LEN+1];    // 当前库的状态或配置信息
#else
    char                    szDescription[WSADESCRIPTION_LEN+1];
    char                    szSystemStatus[WSASYS_STATUS_LEN+1];
    unsigned short          iMaxSockets;
    unsigned short          iMaxUdpDg;
    char FAR *              lpVendorInfo;
#endif
} WSADATA, FAR * LPWSADATA;

WORD是short类型的一个别名。short有两个字节,其低址位的字节存储的是WinSock的主版本号,高址位的字节存储的是WinSock的副版本号。目前,Winsock有1.0,1.1,2.0,2.1,2.2,2.3五个版本。WORD可以使用宏MAKEWORD构造,其定义为:

#define MAKEWORD(a, b) ((WORD)(((BYTE)(((DWORD_PTR)(a)) & 0xff)) | ((WORD)((BYTE)(((DWORD_PTR)(b)) & 0xff))) << 8))

当我们的程序运行结束后,我们需要关闭网络库,这时,可以使用WSACleanup函数完成这个任务:

WSACleanup();

样例代码如下:

#include <WinSock2.h>
#pragma comment(lib, "ws2_32.lib")

int main(void)
{
    WSADATA msg;
    WSAStartup(MAKEWORD(2, 2), &msg);

    // ...

    WSACleanup();
    return 0;
}

WSAStartup函数有返回值,具体含义可以参考MSDN。我们校验返回值,并利用WSADATA的信息进行版本校验:

WSADATA msg;
int errCode = WSAStartup(MAKEWORD(2, 2), &msg);
if (!errCode)
{
    if (LOBYTE(msg.wVersion) != 2 || HIBYTE(msg.wVersion) != 1)
        // 版本号不是2.1
    else
        // 版本号为2.1,且正常打开
}
else
{
    // 打开失败,使用switch语句校验返回值
}

三、建立Socket并将其绑定到端口上

套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。它将底层复杂的协议体系,执行流程,进行了封装,使之成为我们调用协议进行通信的操作接口。

我们使用socket函数创建一个SOCKET,其定义为:

SOCKET WSAAPI socket(
    int af,
    int type,
    int protocol
);

第一个参数af为地址族规范,可以为:AF_UNSPEC(未指定)、AF_INET(IPv4)、AF_INET6(IPv6)、AF_BTH(蓝牙)、AF_IRDA(红外)等宏;第二个参数type为套接字类型,可以为SOCK_STREAM(基于TCP)、SOCK_DGRAM(基于UDP)、SOCK_RAW(原始套接字)、SOCK_RDM(基于PGM)、SOCK_SEQPACKET(根据数据报提供伪流数据包)等宏;第三个参数protocal为协议的类型,可以为:0(让函数自己选择)、IPPROTO_ICMP、IPPROTO_IGMP、BTHPROTO_RFCOMM、IPPROTO_TCP、IPPROTO_UDP、IPPROTO_ICMPV6、IPPROTO_RM等。关于其具体含义,参见MSDN

如果socket创建失败,函数会返回INVALID_SOCKET宏。这时,我们可以使用WSAGetLastError函数获取其错误信息。关于WSAGetLastError函数返回值的具体含义,MSDN上有较为详细的说明。

SOCKET使用完毕后,需要使用closesocket函数关闭socket。它只有一个参数,即使用的SOCKET。

例如,我们可以这样创建一个使用IPv4地址的TCP协议的socket,如果发现创建socket失败,将错误代码(一个int值)作为返回值退出:

SOCKET server_socket = socket(AF_INET, SOCK_STREAM, 13);
if (server_socket == INVALID_SOCKET)
{
    // socket创建失败
    closesocket(server_socket);
    return WSAGetLastError();
}
else
{
    // socket创建成功
    // ...
    closesocket(server_socket);
}

接着,我们就可以要用bind函数将socket与端口、IP地址等绑定。其函数定义为:

int WSAAPI bind(
  SOCKET         s,
  const sockaddr *name,
  int            namelen
);

函数的第一个参数为socket,第二个参数为sockaddr结构体的指针,第三个参数为sockaddr的大小。

sockaddr可以有如下几种类型,由于sockaddr结构过于玄学,我们常用sockaddr_in进行初始化,然后将其强制类型转换为sockaddr。这些结构的定义如下:

struct sockaddr {
    ushort  sa_family;
    char    sa_data[14];
};

struct sockaddr_in {
    short   sin_family;
    u_short sin_port;
    struct  in_addr sin_addr;
    char    sin_zero[8];
};

struct sockaddr_in6 {
    short   sin6_family;
    u_short sin6_port;
    u_long  sin6_flowinfo;
    struct  in6_addr sin6_addr;
    u_long  sin6_scope_id;
};

值得一提的是,in_addr是一个union的结构体,其定义如下:

struct in_addr {
  union {
    struct {
        u_char s_b1;
        u_char s_b2;
        u_char s_b3;
        u_char s_b4;
    } S_un_b;
    struct {
        u_short s_w1;
        u_short s_w2;
    } S_un_w;
    u_long S_addr;
  } S_un;
};

我们可以这样使用bind函数:

struct sockaddr_in saddrIn;
saddrIn.sin_family = AF_INET;
//saddrIn.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");    // 旧式的转换函数,参数是IPv4地址点分十进制字符串
inet_pton(AF_INET, "127.0.0.1", &saddrIn.sin_addr.S_un);    // 新式的转换函数,第一个参数指定IPv4/IPv6
saddrIn.sin_port = htons(6666);                             // 绑定端口为666
// 通过判断bind返回值是否成功。注意,第三个参数为sockaddr结构的大小
if (bind(server_socket, (const struct sockaddr*)(&saddrIn), sizeof(saddrIn)) == SOCKET_ERROR)
{
    // bind失败
    // ...
}

注意,inet_addr和inet_pton是将常见的字符串IP地址转变为而二进制地址。前者是旧式的转换函数,在新的MSVC中会报C4996错误。因此,建议使用后者(需要WSAtcpip.h头文件)。

至此,我们的Socket已经绑定好了端口。

四、listen函数

listen函数的作用是将套接字置于侦听状态。

其函数定义为:

int WSAAPI listen(
    SOCKET s,
    int    backlog
);

第一个参数为SOCKET,第二个参数为挂起连接队列的最大长度。

第一个参数传入我们先前创建的需要侦听的SOCKET;第二个参数可以为一个整型,指定队列长度,也可以是一个SOMAXCONN宏或SOMAXCONN_HINT(N)宏。SOMAXCONN宏能让系统自行选择长度;SOMAXCONN_HINT(N)(其中N是一个数字),能将积压值调整至为N,调整为在范围(200, 65535)内。请注意,SOMAXCONN提示可用于将backlog设置为一个比SOMAXCONN更大的值(见MSDN)。

函数的返回值为一个int。如果成功返回0,否则返回SOCKET_ERROR。关于其错误代码,可以使用WSAGetLastError函数获得。

使用方法例如:

if (listen(server_socket, SOMAXCONN) == SOCKET_ERROR)
{
    // 无法listen。将错误代码作为返回值返回
    return WSAGetLastError();
}

至此,我们的SOCKET就可以开始监听了。

五、使用accept函数创建客户端Socket

accept函数提供了创建客户端的Socket的功能。在我们进行通信的时候,需要把建立连接的双方使用Socket进行抽象。在成功listen的基础上,我们需要通过accept函数创建客户端的Socket。其函数原型如下:

SOCKET WSAAPI accept(
    SOCKET   s,
    sockaddr *addr,
    int      *addrlen
);

函数的第一个参数是本机的Socket;第二个参数是一个指向sockaddr结构的指针,函数执行完后可以在这个结构中获得客户端的信息,如不需要可置为NULL;第三个参数是一个指向一个整型的指针,包含addr参数指向的结构的长度,如不需要可置位NULL。

当然,如果后期我们需要获得某个socket的信息的话,也可以采用getpeername函数获得客户端Socket信息,其参数列表和accept参数相同。

值得注意的是,一个accapt只能获得一个客户端的连接,如为获得连接,则会阻塞直至获得。返回的SOCKET需要检验是否为INVALID_SOCKET,并且需要在使用完成后使用closesocket释放。

六、使用recv和send函数收发信息

Windows API给我们提供了recv和send函数用于收发指定客户端消息。recv函数用于接受消息,send函数用于向目标发送消息。这两个函数的函数定义如下:

int WSAAPI recv(
    SOCKET s,
    char   *buf,
    int    len,
    int    flags
);

int WSAAPI send(
    SOCKET     s,
    const char *buf,
    int        len,
    int        flags
);

recv函数的作用是将协议接收到的信息从系统的缓冲区复制到buf所指向的内存空间中,从而让我们使用。这个buf缓存空间是需要我们手动申请(数组或者动态分配内存)。可以把这块空间的大小设置为1024字节或1500字节。

recv的第三个参数用于指定所需要的消息数组的长度,它决定了最多能读取多少的消息到buf数组中。可以将其设置为buf内存空间的长度 – 1(考虑到结尾的’\0’字符)。

recv的第四个参数用于指定数据读取的方式。通常,我们希望缓冲区内的信息在读取之后被清空,此时填0即可。此外,填MSG_PEEK可以窥视传入的数据,读取完后不删除,但不建议使用。填MSG_OOB可以传输一段带外数据,但依旧不建议使用。填MSG_WAITALL表示等到传入的数据长度与参数三相等时才读取。

一般情况下,recv函数的返回值表示实际返回的字符数组的长度(不计结尾’\0’字符);如果没有接收到客户端信息,则会一直阻塞直到接收到客户端信息。如果连接中断,则会直接返回0;如果接受失败,则会返回SOCKET_ERROR,这时可以使用WSAGetLastError函数获得错误码并进行相应的处理。

send函数与recv参数类似。其第一个参数为目标的SOCKET;第二个参数为字节流的首地址;第三个参数为实际发送信息的长度,为了保证封包后的数据包大小不超过1500,如果len超过了一定值(略小于1500的某个值),否则会被系统自动分片发送;第四个参数则为放的方式。第四个参数通常填0可,还可以填MSG_OOB——与recv语义相同,和MSG_DONTROUTE,指定数据不受路由限制,但可能被Windows套接字服务程序忽略。函数执行成功则返回成功写入的字节数,否则返回SOCKET_ERROR。

七、实例代码

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

#if ((defined _WIN32) || (defined _WIN64))
    #include <WinSock2.h>
    #include <WS2tcpip.h>
#endif

#ifdef _MSC_VER
    #pragma comment(lib, "ws2_32.lib")
#endif

int main(void)
{
    WSADATA wsa_info;
    int errCode = 0;
    if (errCode = WSAStartup(MAKEWORD(2, 2), &wsa_info))
    {
        puts("Failed to Open WSA!");
        return errCode;
    }
    if (LOBYTE(wsa_info.wVersion) != 2 && HIBYTE(wsa_info.wVersion) != 2)
    {
        puts("Version Error!");
        WSACleanup();
        return 0;
    }
    // 版本正确,网络库打开正常。
    SOCKET server = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (server == INVALID_SOCKET)
    {
        // 创建Socket失败
        puts("Faild to create server socket!");
        errCode = WSAGetLastError();
        WSACleanup();
        return errCode;
    }
    // 成功创建SOCKET,绑定信息
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(6666);
    inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr.S_un.S_addr);
    if (bind(server, (const struct sockaddr*) &server_addr, sizeof(const struct sockaddr)) == SOCKET_ERROR)
    {
        // Bind错误
        puts("Bind Error!");
        errCode = WSAGetLastError();
        closesocket(server);
        WSACleanup();
        return errCode;
    }
    if (listen(server, SOMAXCONN) == SOCKET_ERROR)
    {
        puts("Listen Error!");
        errCode = WSAGetLastError();
        closesocket(server);
        WSACleanup();
        return errCode;
    }
    // 成功监听端口,开始获取客户端连接
    while (1)
    {
        SOCKET client = accept(server, NULL, NULL);
        if (client == INVALID_SOCKET)
        {
            printf("Faid to create client socket. Error Code: %d\n", WSAGetLastError());
            continue;
        }
        // 一收,一发
        while (1)
        {
            char temp[1500];
            temp[recv(client, temp, 1499, 0)] = '\0';
            if (!strcmp(temp, "[LogOut]"))
                break;
            puts(temp);
            gets_s(temp, 1400);
            send(client, temp, 1400, 0);
            if (!strcmp(temp, "[LogOut]"))
                break;
        }
        closesocket(client);
    }
    closesocket(server);
    WSACleanup();
    return errCode;     // 无错返回0,否则返回错误码
}
最后修改日期: 2021年8月13日

作者

0 0 投票数
文章评分
订阅评论
提醒
0 评论
内联反馈
查看所有评论