Visual C++ 2017网络编程实战
上QQ阅读APP看书,第一时间看更新

4.2 套接字地址

一个套接字代表通信的一端,每端都有一个套接字地址,这个socket地址包含了IP地址和端口信息。有了IP地址,就能从网络中识别对方主机;有了端口,就能识别对方主机上的进程。

socket地址可以分为通用socket地址和专用socket地址。前者会出现在一些socket API函数中(比如bind函数、connect函数等),这个通用地址原来想用来表示大多数网络地址,但现在有点不方便使用了,因此现在很多网络协议都定义自己的专用网络地址。专用网络地址主要是为了方便使用而提出来的,两者通常可以相互转换。

4.2.1 通用socket地址

通用socket地址就是一个结构体,名字是sockaddr。它定义在ws2def.h中,该结构体如下:

    // Structure used to store most addresses
    typedef struct sockaddr {
    #if (_WIN32_WINNT < 0x0600)
       u_short sa_family;
    #else
       ADDRESS_FAMILY sa_family;           // Address family
    #endif //(_WIN32_WINNT < 0x0600)
       CHAR sa_data[14];                  // Up to 14 bytes of direct address
    } SOCKADDR, *PSOCKADDR, FAR *LPSOCKADDR;

其中,sa_family就是一个无符号短整型(u_short)变量或者ADDRESS_FAMILY枚举类型的变量,该变量用来存放地址簇(或协议簇)类型,常用取值如下:

·PF_UNIX:UNIX本地域协议簇。

·PF_INET:IPv4协议簇。

·PF_INET6:IPv6协议簇。

·AF_UNIX:UNIX本地域地址簇。

·AF_INET:IPv4地址簇。

·AF_INET6:IPv6地址簇。

sa_data用来存放具体的地址数据,即IP地址数据和端口数据。

sa_data只有14字节,随着时代的发展,一些新的协议提出来了,比如IPv6,它的地址长度就不够14字节了。不同协议簇的具体地址长度见表4-1。

表4-1 协议簇的地址含义和长度

sa_data太小,容纳不下了,怎么办?Windows定义了新的通用的地址存储结构:

    typedef struct sockaddr_storage {
       ADDRESS_FAMILY ss_family;      // address family
       
       CHAR __ss_pad1[_SS_PAD1SIZE];  // 6 byte pad, this is to make
                                  //   implementation specific pad up to
                                  //   alignment field that follows explicit
                                  //   in the data structure
       __int64 __ss_align;            // Field to force desired structure
       CHAR __ss_pad2[_SS_PAD2SIZE];  // 112 bytes pad to achieve desired size;
                                  //   _SS_MAXSIZE value minus size of
                                  //   ss_family, __ss_pad1, and
                                  //   __ss_align fields is 112
    } SOCKADDR_STORAGE_LH, *PSOCKADDR_STORAGE_LH, FAR *LPSOCKADDR_STORAGE_LH;

这个结构体存储的地址就大了,而且是内存对齐的,我们可以看到有__ss_align。

4.2.2 专用socket地址

上面两个通用地址结构把IP地址、端口等数据一股脑放到一个char数组中,使得使用起来特不方便。为此,Windows为不同的协议簇定义了不同的socket地址结构体,这些不同的socket地址被称为专用socket地址。比如,IPv4有自己专用的socket地址,IPv6有自己专用的socket地址。

IPv4的socket地址定义了下面的结构体:

    typedef struct sockaddr_in {
    #if(_WIN32_WINNT < 0x0600)
        short   sin_family;
    #else //(_WIN32_WINNT < 0x0600)
        ADDRESS_FAMILY sin_family; //地址簇,取AF_INET
    #endif //(_WIN32_WINNT < 0x0600)
        USHORT sin_port;         //端口号,用网络字节序表示
        IN_ADDR sin_addr;        //IPv4地址结构,用网络字节序表示
        CHAR sin_zero[8];
    } SOCKADDR_IN, *PSOCKADDR_IN;

其中,类型IN_ADDR在inaddr.h中定义如下:

    // IPv4 Internet address
    // This is an 'on-wire' format structure.
    typedef struct in_addr {
            union {
                    struct { UCHAR s_b1,s_b2,s_b3,s_b4; } S_un_b;
                    struct { USHORT s_w1,s_w2; } S_un_w;
                    ULONG S_addr;
            } S_un;
    #define s_addr  S_un.S_addr /* can be used for most tcp & ip code */
    #define s_host  S_un.S_un_b.s_b2    // host on imp
    #define s_net   S_un.S_un_b.s_b1    // network
    #define s_imp   S_un.S_un_w.s_w2    // imp
    #define s_impno S_un.S_un_b.s_b4    // imp #
    #define s_lh    S_un.S_un_b.s_b3    // logical host
    } IN_ADDR, *PIN_ADDR, FAR *LPIN_ADDR;

其中,成员字段S_un用来存放实际的IP地址数据,它是一个32位的联合体(联合体字段S_un_b有4个无符号char型数据,因此取值32位;联合体字段S_un_w有两个USHORT型数据,因此取值32位;联合体字段S_addr是ULONG型数据,因此取值也是32位)。

下面再来看一下IPv6的socket地址专用结构体:

    typedef struct sockaddr_in6 {
        ADDRESS_FAMILY sin6_family; // AF_INET6.
        USHORT sin6_port;         // Transport level port number.
        ULONG  sin6_flowinfo;     // IPv6 flow information.
        IN6_ADDR sin6_addr;       // IPv6 address.
        union {
            ULONG sin6_scope_id;    // Set of interfaces for a scope.
            SCOPE_ID sin6_scope_struct;
        };
    } SOCKADDR_IN6_LH, *PSOCKADDR_IN6_LH, FAR *LPSOCKADDR_IN6_LH;

其中类型IN6_ADDR在in6addr.h中的定义如下:

    // IPv6 Internet address (RFC 2553)
    // This is an 'on-wire' format structure.
    //
    typedef struct in6_addr {
        union {
            UCHAR       Byte[16];
            USHORT      Word[8];
        } u;
    } IN6_ADDR, *PIN6_ADDR, FAR *LPIN6_ADDR;

这些专用的socket地址结构体显然比通用的socket地址更清楚,它把各个信息用不同的字段来表示。需要注意的是,socket API函数使用的是通用地址结构,因此我们具体使用的时候最终要把专用地址结构转换为通用地址结构,不过可以强制转换。

4.2.3 IP地址的转换

IP地址转换是指将点分十进制形式的字符串IP地址与二进制IP地址进行相互转换。比如,“192.168.1.100”就是一个点分十进制形式的字符串IP地址。IP地址转换可以通过inet_aton、inet_addr和inet_ntoa这3个函数完成,这3个地址转换函数都只能处理IPv4地址,而不能处理IPv6地址。使用这些函数需要包含头文件Winsock2.h,并加入库Ws2_32.lib。

函数inet_addr将点分十进制IP地址转换为二进制地址,它返回的结果是网络字节序,该函数声明如下:

    unsigned long inet_addr(  const char* cp);

其中,参数cp指向点分十进制形式的字符串IP地址,如“172.16.2.6”。如果函数成功返回二进制形式的IP地址,类型是32位无符号整型,失败则返回一个常值INADDR_NONE(32位均为1)。通常失败的情况是参数cp所指的字符串IP地址不合法,比如“300.1000.1.1”(超过255了)。宏INADDR_NONE在ws2def.h中定义如下:

    #define INADDR_NONE  0xffffffff

下面我们再看看将结构体in_addr类型的IP地址转换为点分字符串IP地址的函数inet_ntoa,注意这里说的是结构体in_addr类型,即inet_ntoa函数的参数类型是struct in_addr,而不是inet_addr返回的结果unsigned long类型,函数inet_ntoa声明如下:

    char* FAR inet_ntoa(struct  in_addr  in);

其中,in存放struct in_addr类型的IP地址。如函数成功就返回字符串指针,指向转换后的点分十进制IP地址;如果失败就返回NULL。

如果想要把inet_addr的结果再通过函数inet_ntoa转换为字符串形式,该怎么办呢?重要的工作就是要将inet_addr返回的unsigned long类型转换为struct in_addr类型,可以这样:

    struct  in_addr  ia;
    unsigned long dwIP = inet_addr("172.16.2.6");
    ia.s_addr = dwIP;
    printf("real_ip=%s\n", inet_ntoa(ia));

s_addr就是S_un.S_addr。S_un.S_addr是ULONG类型的字段,因此可以先把dwIP直接赋值给ia.s_addr,再把ia传入inet_ntoa中。具体可以看下面的例子。

【例4.1IP地址的字符串和二进制的互转

(1)打开VC++2017,新建一个控制台工程test。

(2)在test.cpp中输入如下代码:

    #include "stdafx.h"

    #define _WINSOCK_DEPRECATED_NO_WARNINGS
    #include <Winsock2.h>

    int main(int argc, const char * argv[])
    {
    struct in_addr ia;
    DWORD dwIP = inet_addr("172.16.2.6");
    ia.s_addr = dwIP;
    printf("ia.s_addr=0x%x\n", ia.s_addr);
    printf("real_ip=%s\n", inet_ntoa(ia));
    return 0;
    }

代码很简单,先把IP172.16.2.6通过函数inet_addr转为二进制并存于ia.s_addr中,然后以十六进制形式打印出来,接着通过函数inet_ntoa转换为点阵的字符串形式。

(3)在工程中加入Ws2_32.lib。

(4)保存工程并运行,运行结果如下:

    ia.s_addr=0x60210ac
    real_ip=172.16.2.6

4.2.4 主机字节序和网络字节序

1.主机字节序

首先要理解字节顺序。所谓字节顺序,是指数据在主机或网络设备(比如路由器)的内存里的存储顺序。

主机字节序就是在主机内部。数据在主机内存中的存储顺序。学过微机原理的朋友应该知道,不同的CPU的字节序是不同的。所谓字节序,就是一个数据的某个字节在内存地址中存放的顺序,即该数据的低位字节是从内存低地址开始存放还是从高地址开始存放。主机字节序通常可以分为两种模式:小端字节序和大端字节序。

为什么会有大、小端模式之分呢?这是因为在计算机系统中,我们是以字节为单位的,一个地址单元(存储单元)都对应着一个字节,即一个存储单元存放一个字节数据。在C语言中,除了8位的char之外,还有16位的short型、32位的long型(要看具体的编译器)。另外,对于位数大于8位的处理器,例如16位或者32位的处理器,由于寄存器宽度大于一个字节,必然存在着多字节安排的问题,因此就导致了大端存储模式和小端存储模式。例如,一个16位的short型x,在内存中的地址为0x0010,x的值为0x1122,那么0x11为高字节,0x22为低字节。对于大端模式,就将0x11放在低地址中,即0x0010中;0x22放在高地址中,即0x0011中。对于小端模式,则刚好相反。我们常用的X86结构是小端模式,而KEIL C51则为大端模式。很多的ARM、DSP都为小端模式。有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。

(1)小端字节序

小端字节序(little-endian)就是数据的低字节存于内存低地址,高字节存于内存高地址。比如一个long型数据0x12345678,采用小端字节序的话,它在内存中的存放情况是这样的:

    0x0029f458  0x78  //低内存地址存放低字节数据
    0x0029f459  0x56
    0x0029f45a  0x34
    0x0029f45b  0x12  //高内存地址存放高字节数据

(2)大端字节序

大端字节序(big-endian)就是数据的高字节存于内存低地址,低字节存于内存高地址。比如一个long型数据0x12345678,采用大端字节序的话,它在内存中的存放情况是这样的:

    0x0029f458  0x12   //低内存地址存放高字节数据
    0x0029f459  0x34
    0x0029f45a  0x56
    0x0029f45b  0x78   //高内存地址存放低字节数据

可以用下面的小例子来测试主机的字节序。

【例4.2】测试主机的字节序

(1)新建一个vc2017控制台工程,工程名是Test。

(2)在test.cpp中输入如下代码:

    #include <iostream>
    using namespace std;

    int main(int argc, char *argv[])
    {
    int nNum = 0x12345678;
    char *p = (char*)&nNum;  //p指向存储nNum的内存的低地址
    //判断低地址是否存放的是数据高位
    if (*p == 0x12) cout << "This machine is big endian." << endl;
    else cout << "This machine is small endian." << endl;

    return 0;
    }

首先定义nNum为int,数据长度为4个字节,然后定义字符指针p指向nNum的地址,因为字符是一个字节,所以赋字符指针p的值时会取出存放nNum的地址最低字节,即p指向低地址。如果*p为0x78(0x78为数据的低位),就为小端;如果*p为0x12(0x12为数据的高位),就为大端。

(3)保存工程并运行,运行结果如图4-2所示。

图4-2

这个机子是x86机子,x86机子基本都是小端模式。

2.网络字节序

在网络上有着各种各样的主机、路由器等网络设备,彼此的机器字节序都是不同的,但由于它们要相互传输存储数据,必须把它们的字节序进行统一,因此人们提出了网络字节序。网络字节序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释。网络字节序采用big endian(大端)排序方式。我们在开发网络程序的时候,应该保证使用网络字节序,为此需要将数据由主机的字节序转换为网络字节序后再发出数据,接收方收到数据后也要先转为主机字节序后再进行处理。这个过程在跨平台开发时尤其重要。

在VC2017中,提供了几个主机字节序和网络字节序相互转换的函数。比如:

    //将uint16_t(16位)类型的数据从主机字节序转为网络字节序
    uint16_t htons(uint16_t hosts);
    //将uint32_t(32位)类型的数据从主机字节序转为网络字节序
    uint32_t htonl(uint32_t  hostl);
    //将uint16_t(16位)类型的数据从网络字节序转为主机字节序
    uint16_t ntohs(uint16_t  nets);
    //将uint32_t(32位)类型的数据从网络字节序转为主机字节序
    uint32_t ntohl(uint32_t  netl);

值得注意的是,对于字节类型,是不存在字节顺序问题的(想想为什么),因此网络编程的发送数据和接收数据的函数的用户缓冲区指针都是字符或字节类型,后面我们会看到这一点。

4.2.5 I/O工作模式和I/O模型

在Windows下,套接字有两种I/O(Input/Output,输入输出)工作模式:阻塞模式(也称同步模式)和非阻塞模式(也称异步模式)。阻塞模式的套接字在一个I/O操作完全结束之前会一直挂起等待,直到该I/O操作完成后再去处理其他I/O操作。对于处于非阻塞模式的套接字,会马上返回而不去等待该I/O操作的完成。针对不同的模式,Winsock提供的函数也有阻塞函数和非阻塞函数。相对而言,阻塞模式比较容易实现中,非阻塞模式就比较复杂了。为了实现套接字的非阻塞模式,微软又提出了套接字的5种I/O模型。

(1)选择模型,或称Select模型,主要是利用Select函数实现对I/O的管理。

(2)异步选择模型,或称WSAAsyncSelect模型,允许应用程序以Windows消息的方式接收网络事件通知。

(3)事件选择模型,WSAEventSelect模型。这个模型类似于WSAAsynSelect模型,两者最主要的区别是在事件选择模型下,网络事件发生时会被发送到一个事件对象句柄,而不是发送到一个窗口。

(4)重叠I/O模型。该模型下可以要求操作系统为你传送数据,并且在传送完毕时通知你。具体实现时可以使用事件通知或者完成例程两种方式分别实现重叠I/O(Overlapped I/O)模型。重叠I/O模型比上述3种模型能达到更佳的系统性能。

(5)完成端口模型。这种模型是最为复杂的一种I/O模型,当然性能也是最强大的。当一个应用程序同时需要管理很多个套接字时,可以采用这种模型,往往可以达到最佳的系统性能。