【网络通信教程】windows 下的 socket API 编程(TCP协议)


准备(以 winsock2 为例)

winsock2 即 windows socket 2,2 是版本号,对应的还有 winsock

  • 常用函数
  1. WSAStartup ()
int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);

  1. WSACleanup ()
int WSACleanup(void);

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

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

  1. listen ()
int listen(SOCKET s, int backlog);

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

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

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

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

  1. closesocket ()
int closesocket(SOCKET s);

  1. WSAGetLastError ()
int WSAGetLastError(void);

  1. select ()
int select(int nfds, fd_set * readfds, fd_set * writefds, fd_set * exceptfds, const struct timeval * timeout);

大概就是这些了。
还有一些高级的用法没有罗列,因为我还没有研究到那里,不过这些已经完全足够了。


简单说一些概念

  你也许根本就没有接触过 socket ,那么在刚刚看了上面这么多奇怪的数据类型,和不知其所云的函数名称,可能有些难以理解。
  不要慌,听我慢慢解释。
  
  首先,看标题最后括号里写的三个字母,以下所有内容均基于 TCP 协议。
  
  TCP 是什么?戳我看百科(度娘是个好东西)
  TCP 常常和 UDP 一起出现,这是两种不同的网络通信协议。
  一般来说,我们认为,TCP 相较于 UDP 更可靠一些。
  
  什么是通信协议?
  其实我们不必知道,因为他已经被封装在了 ws2_32.lib 里面,对于调用者来说,我们不必知道被封装的函数具体的实现方法,我们关心的是更高层的调用逻辑。
  如果非要深究,简单来说,通信协议就是 socket 的实现方法。
  
  对于 TCP 协议,我们需要操作的抽象对象有两个,一个被称为 服务端 ,另一个被称为 客户端 ,是不是非常熟悉呢?


  服务端简介

  1. 服务端需要负责什么?
      作为一个正直的服务端,就应该堂堂正正地出现在客户端们的眼前。所以,服务端应该知道自己到底在哪里。就像一个商铺,客户端要来主动找我们,然后我们才能为它们提供服务。
      也就是说,服务端需要知晓自己的 IP地址端口 ,如此客户端才能朝着我们连接(connect ())。

    对服务端来说,未连接的客户端应该是不可见的。

  客户端简介

  1. 客户端需要负责什么?
      客户端不必知道自己从哪里来,因为根本没人关心。我们关心的是客户端要去哪,把数据交给谁。
      所以,客户端需要知晓服务器的 IP地址端口
    在这里插入图片描述
    对客户端来说,服务端应该像太阳一样站在中间,向所有人敞开怀抱,每个客户端都应该知晓服务端的位置。

着手实现


  服务端实现

  我方才说了,TCP 协议的 socket 需要我们操作两个对象,那么怎么算对象呢?
  是的,不管是服务端还是客户端,我们从始至终都需要操作一个被称为 socket 描述符的东西,他就像是一个句柄。
  如果你足够认真,就会发现,很多函数都需要一个被称为 SOCKET 类型的参数。
  
  听起来很神秘,实际上就是一个整数而已:

typedef unsigned long long SOCKET;

  这个东西在同一时间对于同一主机是唯一的,他是 socket 底层逻辑用来识别不同主机的标志。(客户端和服务端统称为“主机”)
  抽象一下,当两个主机在网络上通信时,数据的入口和出口就是 socket 描述符。
  
  那么,迈向 windows 网络通信的第一只脚是什么呢?
  
  好吧,跟我们料想的并不一样,不是获取一个 socket 描述符。
  再等等,让我们先完成最初的步骤。
  
  第一个需要调用的函数应该是 WSAStartup ()
  在 windows 下,WSAStartup 必须是应用程序或 DLL 调用的第一个 Windows Sockets 函数。
  我把 WSAStartup() 放在常用函数的第一位就是因为这个,我们首先需要调用他。
  很简单,就把它安安静静的放在开头就好了。

我们看一下他的定义及参数:

int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
参数1:windows socket 的版本号。
参数2:指向WSADATA数据结构的指针,用来接收Windows Sockets 实现的细节。
(第二个参数看起来好像很高端,实际上根本没什么用)

对于第一个参数,我们需要使用一个宏,这个宏定义于“winsock2.h”中:

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

而对于第二个看起来很高端,实则没吊用的参数,我们就按照他的要求给他传个指针好了:

WSADATA wsadata;
WSAStartup(MAKEWORD(2, 2), &wsadata);

为了在应用程序当中调用任何一个Winsock API函数,首先第一件事情就是必须通过WSAStartup函数完成对Winsock服务的初始化,因此需要调用WSAStartup函数。使用Socket的程序在使用Socket之前必须调用WSAStartup函数。该函数的第一个参数指明程序请求使用的Socket版本,其中高位字节指明副版本、低位字节指明主版本;操作系统利用第二个参数返回请求的Socket的版本信息。当一个应用程序调用WSAStartup函数时,操作系统根据请求的Socket版本来搜索相应的Socket库,然后绑定找到的Socket库到该应用程序中。以后应用程序就可以调用所请求的Socket库中的其它Socket函数了。——baidu

  下面就正式开始第一步了
  
  
  
  

  1. 获取 socket 描述符
    这一步很简单,仅仅需要简单地调用 socket () 即可

函数定义及参数:

SOCKET socket(int af, int type, int protocol);
参数1:一个地址描述,即地址类型,我们选择 IPv4
参数2:指定socket类型,我们选择流式套接字
参数3:指定socket所用的协议,当然是 TCP 协议了
  • 第一个参数填常量 AF_INET ,即使用 IPv4 进行通信。
  • 第二个参数填常量 SOCK_STREAM,流式套接字。
  • 第三个参数填 IPPROTO_TCP,选用 TCP 协议。

这一步,我们的代码这样写就可以了:

SOCKET server_sock;
server_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

若 socket () 返回 -1 则表示出现错误。
  
  
  
  

  1. 让我们的服务器像太阳一样发光
    我们已经获得了进行网络通信的入口了,现在需要通过 bind () 把我们的地址绑定在我们的入口上,好让客户找到我们。

函数定义及参数:

int bind(SOCKET s, const struct sockaddr * name, int namelen);
参数1:socket 描述符
参数2:一个结构体,包含服务端的 IP 地址及端口信息
参数3:第二个参数的长度
  • 第一个参数填我们刚刚得到的 socket 描述符。
  • 第二个参数需要传一个结构体,

我们先看一下这个结构体的定义:

struct sockaddr {
    unsigned short sa_family;
    CHAR sa_data[14];
};
  • 第一个成员“sa_family”是地址描述,即填我们刚刚在 socket() 函数中传入的第一个参数 AF_INET 即可。

  • 第二个参数就稍微麻烦点了,他包含有 IP 地址及端口信息。

为了处理这个麻烦的结构,“winsock2.h”中还定义了一个更加清晰的结构:

struct sockaddr_in {
    short   sin_family;
    unsigned short sin_port;
    struct in_addr sin_addr;
    unsigned short sin_zero[8];
};
  • 第一个成员仍然是地址描述。
  • 第二个成员是端口信息。
  • 第三个成员是一个结构体,包含 IP 地址。
    用这个数据结构可以轻松处理 socket 地址的相关信息。

给出对这个结构体赋值的例子:

struct sockaddr_in socket_addr;

socket_addr.sin_family = AF_INET;
socket_addr.sin_addr.S_un.S_addr = inet_addr(ip);
socket_addr.sin_port = htons(port);

这里用到了两个新函数,而且你也可能会有一些疑问,这里给出详细解释。
当然,你也可以不用深究,直接跳过此段。

//////////////////////////////////////////////////////////////////////////////////////////

  • 第一个疑问,对于结构体 struct sockaddr_in,我为什么没有解释第四个成员“sin_zero[8]”是干什么的?

  它本身没有意义,仅用来与第一个结构体 struct sockaddr 保持等长。
  有什么用呢?
  他可以让一个指向第一个结构体的指针直接指向第二个结构体,上面说过了,这是一个并列的结构,它们互相兼容。
例如:

bind(..., (struct sockaddr*) &(socket_addr),...);

  

  • 第二个疑问,我们用到了两个新函数 inet_addr ()htons (),为什么我们的 IP 地址和端口需要通过这两个函数进行处理呢?

  这里需要讲到 “网络字节顺序” 和 “主机字节顺序”

网络字节顺序(NBO):网络数据在传输中的规定的数据格式,从高到低位顺序存储,即低字节存储在高地址,高字节存储在低地址;即“大端模式”。网络字节顺序可以避免不同主机字节顺序的差异。

主机字节顺序(HBO):与机器CPU相关,数据的存储顺序由CPU决定。

  用大白话说,当我们在通过网络传输数据时,需要将 “主机字节顺序” 转换到 “网络字节顺序” ,而 htons () 函数就是用来做这种事情的,还有 inet_addr () 则是将 “十进制点分ip” 转换为一个符合结构体格式的整数,而其转换后即为 “网络字节顺序”。

  什么是 “十进制点分ip?
  就是 IP 地址的字符串而已。
  
  非常方便,不是吗?

还有更多:
inet_addr() 将 十进制点分ip 转换为 网络字节顺序
inet_ntoa() 将 网络字节顺序 转换为 十进制点分ip

htons() 将 short 主机字节顺序 转换为 网络字节顺序
ntohs() 将 short 网络字节顺序 转换为 主机字节顺序

ntohl()将 long 主机字节顺序 转换为 网络字节顺序
htonl()将 long 网络字节顺序 转换为 主机字节顺序

//////////////////////////////////////////////////////////////////////////////////////////

疑问已经解释完了,继续进行我们的教程
现在,我们结构体已经赋值完毕了,
在这一步,我们的代码看起来应该是这样子的:

struct sockaddr_in socket_addr;

socket_addr.sin_family = AF_INET;
socket_addr.sin_addr.S_un.S_addr = inet_addr(ip);
socket_addr.sin_port = htons(port);

bind(server_sock, (struct sockaddr*) &(socket_addr),sizeof(struct sockaddr));

同样,若 bind () 返回 -1 则表示出现错误。
  
  
  
  

  1. 监听端口
    我们的服务器已经张开双臂准备迎接客户连接了,这时候我们需要调用 listen (),对我们刚才对结构体赋值的时候填上去的端口进行监听。
    首先说明白,这个函数的意义是开始监听,开始监听后该函数将直接返回,而不是监听直到用户进入。
int listen(SOCKET s, int backlog);
参数1:socket 描述符
参数2:进入队列中允许的连接数目

参数一填服务端的 socket 的描述符即可。
参数二是什么意思呢?进入队列即在你调用 accept() 同意客户接入之前的客户等待队列。
  同一时间可能有很多客户端请求连接,若超出了服务端的处理速度,进入队列会很长,那么多于你定义的队列长度的客户就会被 socket底层 直接忽略掉。

那么很简单,这一步的代码:

listen(server_sock, 2//第二个参数随意填咯

还是那样,返回 -1 代表出错。
  
  
  
  

  1. 接受客户的连接请求
    我们几乎要成功了!
    现在我们要用 accept () 来接受用户的连接请求。
    该函数将会阻塞直到客户请求连接后才返回,返回值为客户在本机的 socket 描述符。这个返回值很重要,在以后发送数据和接收数据时均需要对此描述符进行操作。
SOCKET accept(SOCKET s,struct sockaddr * addr, int * addrlen);
参数1:socket 描述符
参数2:客户的 ip 地址及端口信息结构体指针
参数3:第二个参数的长度的指针
  • 第一个参数还是填服务端的 socket 描述符
  • 第二个参数需要声明一个新的 struct sockaddr,作为客户的地址信息,然后把指针传进去
  • 第三个参数需要传有第二个参数的长度的指针…emmmmm…至于为啥要指针,咱也不知道,咱也不敢问,反正人家让传,咱们传就是了…

这一步的代码:

int size_addr = sizeofstruct sockaddr();
SOCKET client_sock;
struct sockaddr client_addr;

client_sock = accept(server_sock, &(sock_temp.socket_addr), &size_addr);

如果出现错误,该函数返回值仍为 -1
  
  
  
  

  1. 发送数据
    终于到这里了,好兴奋啊!
    我们已经接受了客户的连接请求,双方已经能够清晰的看到对方了。那么我们开始交流吧!
    这一步,我们需要使用的函数是 send ()
int send(SOCKET s, const char * buf, int len, int flags);
参数1:客户的 socket 描述符
参数2:发送的字符串
参数3:字符串的最大长度
参数4:一般置 0 
  • 第一个参数就传由 accept() 返回的客户 socket 描述符,想对谁发送数据,就填他的 socket
  • 第二个参数就是我们想要发送的字符串数据,这里一定保证有效数据的末尾有结束标志‘\0’
  • 第三个参数为我们字符串的最大长度,一般填缓冲区数组长度 - 1,有效保证结尾存在‘\0’
    这一步的代码:
char * buf[1024] = {0};
scanf("%s"&buf);
send(client_sock, buf, 1023, 0);

重复收发数据时,一般我们会先调用 memset() 将缓冲区置0
像这样:

memset(buf, 0, 1024);
scanf("%s"&buf);
send(client_sock, buf, 1023, 0);

  
  
  
  

  1. 让我们来听听客户端的声音
    我们既然可以向客户端发送数据,那么,客户端也就可以向我们发送数据。
    这里需要调用 recv (),它的作用是从 socket 底层的接收缓冲区中 copy 一定长度的字节,需要注意的是,只有数据到达后才会返回,否则会处于阻塞状态。
    而若接收缓冲区中存在数据,即客户在你调用它之前已经发送给数据,那么该函数将立即返回。
    (数据的接收由 socket 的底层逻辑完成,recv () 仅用于 copy 数据)
int recv(SOCKET s, char * buf, int len, int flags);
参数1:客户的 socket 描述符
参数2:接收数据的缓冲区(与上文的“接收缓冲区”不同),即一个指向足够大小字符数组的指针
参数3:想要接收数据的最大长度
参数4:一般置0

与 send () 非常相似,代码如下:

memset(buf, 0, 1024);
recv(client_sock, buf, 1023, 0);

到这里基本上就结束了,非常简单。


  客户端实现

  接下来我再说说客户端,
  客户端的实现与服务端非常相似,而且还简单许多,这是因为需要客户端做的事情真的非常少,我们只需要连接上服务器即可,很多事情都不是客户端需要关心的。
  经过对服务端的学习,现在你应该可以猜到了,我们只需要获得一个 socket 描述符,然后连接服务器即可。

  1. 获取 socket 描述符

不多说,代码一样:

SOCKET client_sock;
client_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

  
  
  
  

  1. 连接服务器
    使用 connect () 来连接服务端:

函数定义及参数:(与 bind () 极其相似,几乎是一个模子刻出来的)

int connect(SOCKET s, const struct sockaddr * name, int namelen);
参数1:socket 描述符
参数2:一个结构体,包含服务端的 IP 地址及端口信息
参数3:第二个参数的长度

如果对细节不太理解,可以回去看一下 bind () 小节的内容。

代码:

struct sockaddr_in socket_addr;

socket_addr.sin_family = AF_INET;
socket_addr.sin_addr.S_un.S_addr = inet_addr(ip);
socket_addr.sin_port = htons(port);

connect(client_sock, (struct sockaddr*) &(socket_addr),sizeof(struct sockaddr));

这里需要注意的是:当我们连接上服务端后,connect () 并不会返回一个新的 socket 描述符作为服务端在本机的化身,而是继续使用我们在开始时定义的 client_sock
  
  
  
  

  1. 使用 recv () 和 send () 收发数据,用法与服务端相同,不再赘述。

  现在我们已经可以运用 TCP 协议自由地收发数据了!


  谈一谈阻塞

  你可能已经注意到了,尽管这样简单地实现了网络通信确实令人兴奋,但我们这样的服务端及客户端的功能也实在有限。
  阻塞,大白话说,就是卡在那。
  就像服务端的 accept() ,他会在那里等待着,直到用户连接才返回。若我们想要在等待连接的同时去接收数据,那么以上的知识确实捉襟见肘。
  实际上,这里并非说 accept () 函数是阻塞性质的函数,而是说,我们的 socket 描述符是阻塞性质的描述符。
  在 windows 下,任何创建出来的(包括 accept () 返回的)socket 描述符都被默认为阻塞模式,如果不想这样,我们可以调用 ioctlsocket () 函数来设置某个 socket 描述符为非阻塞模式,这样,我们在调用 accept () 时,它会立即返回,如果没有什么意外,会返回一个非负值以示调用成功。
  不过我不想讲非阻塞模式下的 socket API 编程,要实现他会很麻烦,而且需要有一定技巧,同时也会浪费很多系统资源。
  最重要的是:我还没有研究过他(逃)
  
  那么,要完成 非阻塞的网络通信,难道就没有其他方法了吗?


  通过 select () 实现非阻塞的服务端及客户端

  别急,我们还有最后一个函数 select () 没讲呢!
  距离成功还剩下最后一步!

int select(int nfds, fd_set * readfds, fd_set * writefds, fd_set * exceptfds, const struct timeval * timeout);

  select () 是干嘛用的?
  顾名思义,他会选择出一个东西来。

  选择什么呢?
  选择出当前可读写或异常的描述符来。

该函数用于监视文件描述符的变化情况——读写或是异常。——baidu

我们先来解释一下它的参数:

int select(int nfds, fd_set * readfds, fd_set * writefds, fd_set * exceptfds, const struct timeval * timeout);
参数1:最大的文件描述符加1
参数2:欲检查可读性的文件描述符集合
参数3:欲检查可写性的文件描述符集合
参数4:欲检查带外数据的文件描述符集合
参数5:一个指向 timeval 结构的指针

emmm…说实话,我第一次看见它的时候也想跳过,不过实际上它真的很简单,听我慢慢解释!

  • 第一个参数,什么?什么最大文件描述符加1?
      如果你不理解的话,就不用理解了,因为他在 windows 下只是为了保持兼容性而存在,你往里填什么都行。

  • 第二、三、四个参数是一种东西,我统一来解释。
      他要求传入一个 fd_set 类型的数据,我们可以把它抽象为一个集合,在 socket 的范围里,这个集合里包含有我们想要添加进去的 socket 描述符。
      如果你想让 select () 监视一个 socket 描述符,那么就把它添加进这个集合中即可。

  如何添加?
  实际上,fd_set 是一个结构体。
  事情好像变得麻烦了起来吗?
  并没有,我们不必去关注它的实现细节,因为它已经被前辈们贴心地定义为了四个宏。

FD_SET(int fd, fd_set * set)// - 添加fd到集合
FD_CLR(int fd, fd_set * set) //- 从集合中移去fd
FD_ISSET(int fd, fd_set * set)// - 测试fd是否在集合中
FD_ZERO(fd_set * set) //- 清除一个文件描述符集合

具体定义可以在 “winsock2.h” 中找到。

  • 第五个参数也很简单,一个指向结构体 timeval 的指针。
    这是其定义:
struct timeval {
        long    tv_sec;         /* seconds */
        long    tv_usec;        /* and microseconds */
};

  它用来表示一个时间长度,第一个成员是秒数,第二个成员是微秒数(居然是微秒!),两者加起来就是我们要表示的时间长度。
  传入 select () 后,此数据结构即代表 select () 的超时时间
  若将此结构全部置 0 ,select () 将立即返回。
  若此参数传入 NULL ,则 select () 将永远等待,直到下一个文件描述符可读写时返回。

  我们该如何运用 select () ?
  当 select () 监视的某个文件描述符可读写时将会立即返回,并更改文件描述符集合使其仅留下可读写的文件描述符。然后我们便可以根据上述宏的其中之一来判断到底是谁可读写。

FD_ISSET(int fd, fd_set * set)// - 测试fd是否在集合中

  要注意的是,我们需要保存文件描述符集合的初始状态,并在下一次调用 select () 前为传入的文件描述符集合重新赋值。
  因为每次返回时,传入的文件描述符集合已经被修改,所以我们要保证在下一次调用 select () 前,传入的文件描述符集合里面确实存在我们需要监视的 socket 描述符。

代码大概是这样的:

...仅演示 select () 的一般用法...之前的代码省略...
typedef struct socket_state {
	SOCKET socket;
	SOCKADDR_IN socket_addr;
}Client_state, Server_state;//方便处理客户信息

while (1)
	{
		select(0, &temp_REDfd, NULL, NULL, &timv);
		
		//1. 首先判断 server 的 listen 
		if (FD_ISSET(server.socket, &temp_REDfd))
		{//client 请求连接
			...
			...调用 accept () 处理连接请求
			...
			FD_SET(s_client[arr_size].socket, &source_REDfd);//添加到主描述符集合
		}

		//2. 再判断各 client 是否可读 
		for (int i = 0; i <= arr_size; i++)
		{
			if (FD_ISSET(s_client[i].socket, &temp_REDfd))
			{
				...
				...调用 recv () 接收数据
				...
			}
		}
		temp_REDfd = source_REDfd;//复原集合
	}

动手写一写,你会发现它很简单。


另外,我写了一套 socket 接口,
其中包含两个主要接口,可以方便地创建一个服务端及客户端,并提供了数个回调函数。

  我上传在了 CSDN 上:
https://download.csdn.net/download/qq_16181837/12027371

压缩文件中包含:
1.静态库
2.源文件
3.头文件
4.服务端和客户端的 demo


感谢

感谢这篇 blog 的作者,讲解地非常系统,非常全面。
如果你想对 socket 继续深究,大可读一下这篇文章。
http://blog.sina.com.cn/s/blog_79b01f66010163q3.html

posted @ 2019-12-04 01:52  高厉害  阅读(1245)  评论(0编辑  收藏  举报