C++ 黑客编程揭秘与防范(第3版)
上QQ阅读APP看书,第一时间看更新

2.1.3 Winsock网络编程知识

Winsock是Windows下网络编程的基础。本小节介绍Winsock的常用函数。

1.Winsock的初始化与释放

在使用Winsock相关函数时需要对Winsock库进行初始化,而在使用完成后需要对Winsock库进行释放。完成Winsock库的初始化和释放的函数如下。

Winsock库的初始化函数的定义:

        int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);

该函数的第1个参数wVersionRequested是需要初始化Winsock库的版本号,Winsock库有多个版本,目前常用的版本是2.2。第2个参数lpWSAData是一个指向WSADATA的指针。该函数的返回值为0,说明函数调用成功。如果函数调用失败,则返回其他值。在程序的开始处调用该初始化函数,在程序中就可以使用Winsock相关的所有API函数。

Winsock库的释放函数的定义:

        int  WSACleanup (void);

该函数没有参数,在程序的结束处直接调用该函数,即可释放Winsock库。

初始化与释放Winsock库的代码示例如下:

              WORD wVersionRequested;
              WSADATA wsaData;
              int err;

              wVersionRequested = MAKEWORD( 2, 2 );

              err = WSAStartup( wVersionRequested, &wsaData );
              if ( err != 0 )
              {
                  return -1;
              }

              if ( LOBYTE( wsaData.wVersion ) != 2 ||
                  HIBYTE( wsaData.wVersion ) != 2 )
              {
                  WSACleanup( );
                  return -1;
              }

              // ……

              WSACleanup();

2.套接字的创建与关闭

套接字用于根据指定的协议类型来分配一个套接字描述符。该描述符主要用在客户端和服务器端进行通信连接,当套接字使用完毕时应该关闭套接字以释放资源。创建套接字与关闭套接字的函数为socket()和closesocket()。

创建套接字的函数定义如下:

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

socket()函数共有3个参数,第1个参数af用来指定地址族,在Windows下可以使用的参数值有多个,但是真正可以使用的只有两个,分别是AF_INET和PF_INET。这两个宏在Winsock2.h下的定义是相同的,分别如下:

        #define AF_INET 2 /* internetwork: UDP, TCP, etc. */
        /*
          * Protocol families, same as address families for now.
          */
        #define PF_INET          AF_INET

以上两个定义都摘自Winsock2.h头文件。从定义中可以看出,PF_INET和AF_INET是相同的。看PF_INET宏定义上面的注释,AF表示地址族(Address Family),而PF表示协议族(Protocol Family)。对于Windows来说,两者相同;对于Unix/Linux来说,两者是不相同的。一般情况下,在调用socket()函数时应该使用PF_INET,而在设置地址时使用AF_INET。FP_INET上面的那句注释,同样也是出自Winsock2.h头文件中。“Protocol families,same as address families for now.”,也就是说,目前PF和AF是相同的。注释中说目前是相同的,可能这样定义是为以后预留的,为了保持良好的兼容性。调用socket()函数时,应该使用PF_INET宏,而尽量避免或不去使用AF_INET宏。

socket()函数的第2个参数type是指定新套接字描述符的类型。这里可以使用的值通常有3个,分别是SOCK_STREAM、SOCK_DGRAM和SOCK_RAW,分别表示流套接字、数据包套接字和原始协议接口。

socket()函数的第3个参数protocol用来指定应用程序所使用的通信协议,这里可以选择使用IPPROTO_TCP、IPPROTO_UDP、IPPROTO_ICMP等协议,这个参数的值根据第2个参数的值进行选择。第2个参数如果使用SOCK_STREAM,那么第3个参数应该使用IPPROTO_TCP;如果第3个参数使用了SOCK_DGRAM,那么第3个参数应该使用IPPROTO_UDP。为了书写简单,如果第2个参数是SOCK_STREAM或SOCK_DGRAM,那么第3个参数可以默认为0。如果第2个参数指定的是SOCK_RAW,那么第3个参数就必须指定,而不能使用0值。

socket()函数调用成功返回值为一个新的套接字描述符,如果调用失败,则返回INVALID_SOCKET。调用失败后,想要知道调用失败的原因,那么紧接着调用WSAGetLastError()函数得到错误码。

:所有的Winsock函数出错后,都可以调用WSAGetLastError()函数得到错误码,但是WSAStartup()不能通过WSAGetLastError()得到错误码,因为WSAStartup()未调用成功,不能调用WSAGetLastError()函数。

关闭套接字的函数定义如下:

        int closesocket(SOCKET s);

closesocket()函数的参数是socket()函数创建的套接字描述符。

:对于WSAStartup()/WSACleanup()和socket()/closesocket()这样的函数,最好保持成对出现。也就是说,在写完一个函数时,立刻写出另外一个函数的调用,以免忘记资源的释放。

3.面向连接协议的函数

前面的部分提到了面向连接协议与非面向连接协议所用到的函数是不相同的。这里来介绍面向连接协议的函数:bind()、listen()、accept()、connect()、send()和recv()。这些函数是常用的面向连接的函数,它们都是Winsock面向连接的最基本的函数。Winsock库的函数非常多,这里只是寥寥介绍几个而已,更多的Winsock函数需要在不断的实践中去学习。下面介绍以上几个函数的使用方法。

通过socket()函数可以创建一个新的套接字描述符,但是它只是一个描述符,它为网络的一些资源做准备。要真正在网络上进行通信,需要本地的地址与本地的端口号信息。当然,本地地址与端口号信息要去套接字描述符进行关联进行绑定。在Winsock函数中,使用bind()函数完成套接字与地址端口信息的绑定。bind()函数的定义如下:

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

该函数有3个参数,第1个参数s是新创建的套接字描述符,也就是用socket()函数创建的描述符,第2个参数name是一个sockaddr的结构体,提供套接字一个地址和端口信息,第3个参数namelen是sockaddr结构体的大小。

其中第2个参数中的sockaddr结构体定义如下:

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

该结构体共有16字节,在该结构体之前所使用的结构体为sockaddr_in,该结构体的定义如下:

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

sockaddr结构体是为了保持各个特定协议之间的结构体兼容性而设计的。为bind()函数指定地址和端口时,向sockaddr_in结构体填充相应的内容,而调用函数时应该使用sockaddr结构体。

在sockaddr_in结构体中,还有一个结构体in_addr,该结构体在winsock2.h中的定义如下:

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

该结构体中是一个共用体S_un,包含两个结构体变量和1个u_long类型变量。一般使用的IP地址是使用点分十进制表示的,而in_addr结构体中却没有提供用来保存点分十进制表示IP地址的数据类型,这时需要使用转换函数,把点分十进制表示的IP地址转换成in_addr结构体可以接受的类型。这里使用的转换函数是inet_addr(),该函数的定义如下:

        unsigned long inet_addr(const char    FAR *cp);

该函数是将点分十进制表示IP地址转换成unsigned long类型的数值。该函数的参数cp是指向点分十进制IP地址的字符指针。同时该函数有一个逆函数,是将unsigned long型的数值型IP地址转换为点分十进制的IP地址字符串,该函数的定义如下:

        char FAR * inet_ntoa(struct    in_addr in);

sockaddr_in结构体中的sin_port表示端口,这个端口需要使用大尾方式字节序存储(大尾方式和小尾方式是两种不同的存储方式。为了不影响内容的结构,将在后面介绍这两种存储方式,这里先讨论如何使用大尾方式)。在Intel X86架构下,数值存储方式默认都是小尾方式字节序,而TCP/IP的数值存储方式都是大尾方式的字节序。为了实现方便的转换,winsock2.h中提供了方便的函数,即htons()和htonl()两个函数,并且提供了它们的逆函数ntohs()和ntohl()。

htons()和htonl()函数的定义分别如下:

        u_short htons(u_short hostshort);
        u_long htonl(u_long hostlong);

ntohs()和ntohl()函数的定义分别如下:

        u_short ntohs(u_short netshort);
        u_long ntohl(u_long netlong);

这4个函数中,前两个函数是将主机字节序转换为网络字节序(host to network),后两个函数是将网络字节序转换为主机字节序(network to host)。在有些架构系统下,主机字节序和网络字节序是相同的,那样转换函数不进行任何转换,但是为了代码的移植性,还是会进行转换函数的调用。

具体bind()函数的使用方法如下:

        // 创建套接字
        SOCKET sLisent = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

        // 对sockaddr_in结构体填充地址、端口等信息
        struct sockaddr_in ServerAddr;
        ServerAddr.sin_family = AF_INET;
        ServerAddr.sin_addr.S_un.S_addr = inet_addr("10.10.30.12");
        ServerAddr.sin_port = htons(1234);

        // 绑定套接字与地址信息
        bind(sLisent, (SOCKADDR *)&ServerAddr, sizeof(ServerAddr));

:对于服务器端的地址可以指定为INADDR_ANY宏,表示“任意地址”或“所有地址”。当客户端发起连接时,服务器操作系统接收到客户端的连接,根据网络的配置情况会自动选择一个IP地址和客户端进行通信。

当套接字与地址端口信息绑定以后,就需要让端口进行监听,当端口处于监听状态以后就可以接受其他主机的连接了。监听端口和接受连接请求的函数分别为listen()和accept()。

监听端口的函数定义如下:

        int listen(SOCKET s, int backlog);

该函数有两个参数,第1个参数s是指定要监听的套接字描述符,第2个参数backlog是允许进入请求连接队列的个数,backlog的最大值由系统指定,在winsock2.h中,其最大值由SOMAXCONN表示,该值的定义如下:

        #define SOMAXCONN       0x7fffffff

接受连接请求的函数定义如下:

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

该函数从连接请求队列中获得连接信息,创建新的套接字描述符,获取客户端地址。新创建的套接字用于和客户端进行通信,在服务器和客户端通信完成后,该套接字也需要使用closesocket()函数进行关闭,以释放相应的资源。该函数有3个参数,第1个参数s是处于监听的套接字描述符,第2个参数addr是一个指向sockaddr结构体的指针,用来返回客户端的地址信息,第3个参数addrlen是一个指向int型的指针变量,用来传入sockaddr结构体的大小。

上面介绍的是面向连接的服务器端的函数,完成了一系列服务器应有的基本的动作,具体如下。

① bind()函数将套接字描述符与地址信息进行绑定。

② listen()函数将绑定过套接字描述符置于监听状态。

③ accept()函数获取连接队列中的连接信息,创建新的套接字描述符,以便与客户端通信。

面向连接的客户端只需要完成与服务器的连接这样一个动作就可以实现和服务器端的通信了。创建套接字描述符后,使用connect()函数就可以完成与服务器的连接。

connect()函数的定义如下:

        int connect(SOCKET s, const struct sockaddr FAR *name, int namelen);

该函数的作用是将套接字进行连接。该函数有3个参数,第1个参数s表示创建好的套接字描述符,第2个参数name是指向sockaddr结构体的指针,sockaddr结构体中保存了服务器的IP地址和端口号,第3个参数namelen是指定sockaddr结构体的长度。

当客户端使用connect()函数与服务器连接后,客户端和服务器就可以进行通信了。通信时主要就是信息的发送和信息的接收。这里介绍的函数有两个,分别是send()和recv()。

发送函数send()的定义如下:

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

该函数有4个参数,第1个参数s是套接字描述符,该套接字描述符对于服务器端而言,使用的是accept()函数返回的套接字描述符,对于客户端而言,使用的是socket()函数创建的套接字描述符,第2个参数buf是发送消息的缓冲区,第3个参数len是缓冲区的长度,第4个参数flags通常赋为0值。

接收函数recv()的定义如下:

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

该函数有4个参数。该函数的使用方法与send()函数的使用方法相同,这里不再进行介绍。

:从send()和recv()两个函数的名称来看分别是发送和接受的意思,但是实际上对于数据的发送和接收依靠的是网络协议来完成的,send()函数和recv()函数只是完成了将数据从网络协议所使用的缓冲区中进行拷贝的一个动作。

4.非面向连接协议的函数

在面向连接的TCP中,服务器端将套接字描述符与地址进行绑定后,需要将端口进行监听,等待接受客户端的连接请求,而在客户端则需要连接服务器,完成这些步骤就可以保证面向连接的TCP的可靠传输,在调用connect()函数的过程中也完成了TCP的“三次握手”的过程。非面向连接的UDP协议在开发上基本与面向连接TCP的协议相同。在非面向连接的UDP开发中,服务器端不需要对端口进行监听,也就不需要等待接受客户端的连接请求,而客户端也不需要完成与服务器端的连接。中间的“三次握手”过程也就省略了,这样UDP相对于TCP来讲就显得不可靠了,但是在效率方面却要快于TCP。

在非面向连接协议开发中,服务器端不再需要调用listen()、accept()函数,客户端不再需要调用connect()函数。而服务器和客户端的通信函数使用sendto()和recvfrom()函数即可。

sendto()函数的定义如下:

        int sendto(
          SOCKET s,
          const char FAR *buf,
          int len,
          int flags,
          const struct sockaddr FAR *to,
          int tolen
        );

该函数是来用在UDP通信双方进行发送数据的函数,该函数有6个参数,第1个参数s是套接字描述符,第2个参数buf是要发送数据的缓冲区,第3个参数len是指定第2个参数的长度,第4个参数通常赋0值,第5个参数to是一个指向sockaddr结构体的指针,这里给出接收消息的地址信息,第6个参数tolen是指定第5个参数的长度。

recvfrom()函数的定义如下:

        int recvfrom(
          SOCKET s,
          char FAR* buf,
          int len,
          int flags,
          struct sockaddr FAR *from,
          int FAR *fromlen
        );

该函数是用来在UDP通信双方进行接收数据的函数。该函数的用法与sendto()相同,这里不再进行介绍。

:sendto()函数和recvfrom()函数的功能与send()函数和recv()函数类似,它们都是用于向网络协议缓冲区进行数据复制的函数,并不是真正的去完成数据的发送和接收的。